aqualink 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,3 @@
1
- 'use strict'
2
-
3
1
  const { AqualinkEvents } = require('./AqualinkEvents')
4
2
 
5
3
  const POOL_SIZE = 12
@@ -50,6 +48,17 @@ const _functions = {
50
48
  const label = (dot === -1 ? endpoint : endpoint.slice(0, dot)).toLowerCase()
51
49
  if (!label) return 'unknown'
52
50
 
51
+ // Discord voice hosts commonly look like: c-gru20-<hash>.discord.media
52
+ const cPrefix = /^c-([a-z]{3})(?:\d+)?(?:-|$)/
53
+ const m1 = cPrefix.exec(label)
54
+ if (m1?.[1]) return m1[1]
55
+
56
+ // Fallback for labels that still contain an iata-like token.
57
+ const token = /(?:^|-)([a-z]{3})(?:\d+)?(?:-|$)/
58
+ const m2 = token.exec(label)
59
+ if (m2?.[1]) return m2[1]
60
+
61
+ // Last fallback: strip trailing digits from first label.
53
62
  let i = label.length - 1
54
63
  while (i >= 0) {
55
64
  const c = label.charCodeAt(i)
@@ -58,15 +67,13 @@ const _functions = {
58
67
  }
59
68
  return label.slice(0, i + 1) || 'unknown'
60
69
  },
61
- fillVoicePayload: (payload, guildId, conn, player, resume) => {
70
+ fillVoicePayload: (payload, guildId, conn, player) => {
62
71
  payload.guildId = guildId
63
72
  const v = payload.data.voice
64
73
  v.token = conn.token
65
74
  v.endpoint = conn.endpoint
66
75
  v.sessionId = conn.sessionId
67
76
  v.channelId = player.voiceChannel
68
- v.resume = resume ? true : undefined
69
- v.sequence = resume ? conn.sequence : undefined
70
77
  payload.data.volume = player?.volume ?? 100
71
78
  return payload
72
79
  }
@@ -85,9 +92,7 @@ class PayloadPool {
85
92
  voice: {
86
93
  token: null,
87
94
  endpoint: null,
88
- sessionId: null,
89
- resume: undefined,
90
- sequence: undefined
95
+ sessionId: null
91
96
  },
92
97
  volume: null
93
98
  }
@@ -103,7 +108,6 @@ class PayloadPool {
103
108
  payload.guildId = null
104
109
  const v = payload.data.voice
105
110
  v.token = v.endpoint = v.sessionId = null
106
- v.resume = v.sequence = undefined
107
111
  payload.data.volume = null
108
112
  this._pool[this._size++] = payload
109
113
  }
@@ -153,6 +157,7 @@ class Connection {
153
157
 
154
158
  this._lastStateReqAt = 0
155
159
  this._stateGeneration = 0
160
+ this._regionMigrationAttempted = false
156
161
  }
157
162
 
158
163
  _hasValidVoiceData() {
@@ -203,6 +208,7 @@ class Connection {
203
208
  this._lastEndpoint = endpoint
204
209
  this._reconnectAttempts = 0
205
210
  this._consecutiveFailures = 0
211
+ this._regionMigrationAttempted = false
206
212
  }
207
213
 
208
214
  this.endpoint = endpoint
@@ -210,14 +216,76 @@ class Connection {
210
216
  this.token = data.token
211
217
  this.channelId = data.channel_id || this.channelId || this.voiceChannel
212
218
  this._lastVoiceDataUpdate = Date.now()
219
+ this._aqua?._trace?.('connection.serverUpdate', {
220
+ guildId: this._guildId,
221
+ endpoint: this.endpoint,
222
+ region: this.region,
223
+ txId: data.txId || null
224
+ })
213
225
  this._stateFlags &= ~STATE.VOICE_DATA_STALE
214
226
 
215
227
  if (this._player?.paused) this._player.pause(false)
228
+ const migrated = this._checkRegionMigration()
229
+ if (migrated) return
216
230
  this._scheduleVoiceUpdate()
231
+ this._player?._flushDeferredPlay?.()
232
+ }
233
+
234
+ _checkRegionMigration() {
235
+ if (this._destroyed || this._regionMigrationAttempted) return false
236
+ if (
237
+ !this._aqua?.autoRegionMigrate ||
238
+ !this.region ||
239
+ this.region === 'unknown'
240
+ )
241
+ return false
242
+ const player = this._player
243
+ if (!player || player.destroyed || player._resuming || player._reconnecting)
244
+ return false
245
+
246
+ const currentNode = player.nodes
247
+ if (!currentNode) return false
248
+
249
+ const currentRegions = Array.isArray(currentNode.regions)
250
+ ? currentNode.regions
251
+ : []
252
+ const alreadyMatching = currentRegions.some((r) =>
253
+ this._aqua._regionMatches?.(r, this.region)
254
+ )
255
+ if (alreadyMatching) {
256
+ this._regionMigrationAttempted = true
257
+ return false
258
+ }
259
+
260
+ const targetNode = this._aqua._findBestNodeForRegion?.(this.region)
261
+ if (!targetNode || targetNode === currentNode) return false
262
+
263
+ this._regionMigrationAttempted = true
264
+ this._aqua?._trace?.('connection.region.migrate', {
265
+ guildId: this._guildId,
266
+ region: this.region,
267
+ from: currentNode?.name || currentNode?.host,
268
+ to: targetNode?.name || targetNode?.host
269
+ })
270
+
271
+ queueMicrotask(() => {
272
+ this._aqua
273
+ .movePlayerToNode?.(this._guildId, targetNode, 'region')
274
+ .catch((err) => {
275
+ this._regionMigrationAttempted = false
276
+ this._aqua?._trace?.('connection.region.migrate.error', {
277
+ guildId: this._guildId,
278
+ region: this.region,
279
+ error: err?.message || String(err)
280
+ })
281
+ })
282
+ })
283
+ return true
217
284
  }
218
285
 
219
- resendVoiceUpdate() {
286
+ resendVoiceUpdate(force = false) {
220
287
  if (this._destroyed || !this._hasValidVoiceData()) return false
288
+ if (force) this._lastSentVoiceKey = ''
221
289
  this._scheduleVoiceUpdate()
222
290
  return true
223
291
  }
@@ -238,6 +306,10 @@ class Connection {
238
306
  if (data.txId && data.txId < this.txId) return
239
307
 
240
308
  if (!channelId) {
309
+ this._aqua?._trace?.('connection.stateUpdate.nullChannel', {
310
+ guildId: this._guildId,
311
+ txId: data.txId || null
312
+ })
241
313
  this.isWaitingForDisconnect = true
242
314
  if (!this._nullChannelTimer) {
243
315
  this._nullChannelTimer = setTimeout(() => {
@@ -250,6 +322,12 @@ class Connection {
250
322
  }
251
323
 
252
324
  this.isWaitingForDisconnect = false
325
+ this._aqua?._trace?.('connection.stateUpdate', {
326
+ guildId: this._guildId,
327
+ channelId,
328
+ sessionId,
329
+ txId: data.txId || null
330
+ })
253
331
 
254
332
  if (p && p.txId > this.txId) this.txId = p.txId
255
333
 
@@ -290,6 +368,9 @@ class Connection {
290
368
 
291
369
  this._stateFlags =
292
370
  (this._stateFlags | STATE.DISCONNECTING) & ~STATE.CONNECTED
371
+ this._aqua?._trace?.('connection.disconnect', {
372
+ guildId: this._guildId
373
+ })
293
374
  this._clearNullChannelTimer()
294
375
  this._clearPendingUpdate()
295
376
  this._clearReconnectTimer()
@@ -339,6 +420,13 @@ class Connection {
339
420
 
340
421
  async attemptResume() {
341
422
  if (!this._canAttemptResumeCore()) return false
423
+ this._aqua?._trace?.('connection.resume.attempt', {
424
+ guildId: this._guildId,
425
+ reconnectAttempts: this._reconnectAttempts,
426
+ hasSessionId: !!this.sessionId,
427
+ hasEndpoint: !!this.endpoint,
428
+ hasToken: !!this.token
429
+ })
342
430
 
343
431
  const currentGen = this._stateGeneration
344
432
 
@@ -383,6 +471,9 @@ class Connection {
383
471
  }
384
472
 
385
473
  await this._sendUpdate(payload)
474
+ this._aqua?._trace?.('connection.resume.success', {
475
+ guildId: this._guildId
476
+ })
386
477
 
387
478
  this._reconnectAttempts = 0
388
479
  this._consecutiveFailures = 0
@@ -400,6 +491,10 @@ class Connection {
400
491
  AqualinkEvents.Debug,
401
492
  `Resume failed for guild ${this._guildId}: ${e?.message || e}`
402
493
  )
494
+ this._aqua?._trace?.('connection.resume.error', {
495
+ guildId: this._guildId,
496
+ error: e?.message || String(e)
497
+ })
403
498
 
404
499
  if (
405
500
  this._reconnectAttempts < MAX_RECONNECT_ATTEMPTS &&
@@ -456,22 +551,27 @@ class Connection {
456
551
  _makeVoiceKey() {
457
552
  const p = this._player
458
553
  const vol = p?.volume ?? 100
459
- return (
460
- (this.sessionId || '') +
461
- '|' +
462
- (this.token || '') +
463
- '|' +
464
- (this.endpoint || '') +
465
- '|' +
466
- (p?.voiceChannel || '') +
467
- '|' +
468
- vol
469
- )
554
+ return `${this.sessionId || ''}|${this.token || ''}|${this.endpoint || ''}|${p?.voiceChannel || ''}|${vol}`
470
555
  }
471
556
 
472
557
  _scheduleVoiceUpdate() {
473
- if (this._destroyed) return
474
- if (!this._hasValidVoiceData()) return
558
+ if (this._destroyed) {
559
+ this._aqua?._trace?.('connection.update.skip', {
560
+ guildId: this._guildId,
561
+ reason: 'destroyed'
562
+ })
563
+ return
564
+ }
565
+ if (!this._hasValidVoiceData()) {
566
+ this._aqua?._trace?.('connection.update.skip', {
567
+ guildId: this._guildId,
568
+ reason: 'invalid_voice_data',
569
+ hasSessionId: !!this.sessionId,
570
+ hasEndpoint: !!this.endpoint,
571
+ hasToken: !!this.token
572
+ })
573
+ return
574
+ }
475
575
 
476
576
  if (!this._pendingUpdate) {
477
577
  const payload = sharedPool.acquire()
@@ -496,6 +596,9 @@ class Connection {
496
596
 
497
597
  if (this._stateFlags & STATE.UPDATE_SCHEDULED) return
498
598
  this._stateFlags |= STATE.UPDATE_SCHEDULED
599
+ this._aqua?._trace?.('connection.update.scheduled', {
600
+ guildId: this._guildId
601
+ })
499
602
 
500
603
  this._voiceFlushTimer = setTimeout(
501
604
  () => this._executeVoiceUpdate(),
@@ -535,14 +638,32 @@ class Connection {
535
638
  if (!this._rest) throw new Error('REST interface unavailable')
536
639
 
537
640
  try {
641
+ this._aqua?._trace?.('connection.update.send', {
642
+ guildId: this._guildId,
643
+ hasSessionId: !!this._rest?.sessionId,
644
+ hasVoice:
645
+ !!payload?.data?.voice?.sessionId && !!payload?.data?.voice?.endpoint
646
+ })
538
647
  await this._rest.updatePlayer(payload)
648
+ this._aqua?._trace?.('connection.update.ok', {
649
+ guildId: this._guildId
650
+ })
539
651
  } catch (e) {
652
+ this._aqua?._trace?.('connection.update.error', {
653
+ guildId: this._guildId,
654
+ statusCode: e?.statusCode || e?.response?.statusCode || null,
655
+ error: e?.message || String(e)
656
+ })
540
657
  if (e.statusCode === 404 || e.response?.statusCode === 404) {
658
+ const isSessionError = e.body?.message?.includes('sessionId') || false
541
659
  if (this._aqua) {
542
660
  this._aqua.emit(
543
661
  AqualinkEvents.Debug,
544
- `Player ${this._guildId} not found (404). Destroying.`
662
+ `[Aqua/Connection] Player ${this._guildId} not found (404)${isSessionError ? ' - Session invalid' : ''}. Destroying.`
545
663
  )
664
+ if (isSessionError && this._player?.nodes?._clearSession) {
665
+ this._player.nodes._clearSession()
666
+ }
546
667
  await this._aqua.destroyPlayer(this._guildId)
547
668
  }
548
669
  throw e
@@ -1,5 +1,3 @@
1
- 'use strict'
2
-
3
1
  const FILTER_DEFAULTS = Object.freeze({
4
2
  karaoke: Object.freeze({
5
3
  level: 1,
@@ -41,6 +39,27 @@ const FILTER_KEYS = Object.freeze(
41
39
 
42
40
  const EMPTY_ARRAY = Object.freeze([])
43
41
 
42
+ const FILTER_POOL_SIZE = 16
43
+ const filterPool = {
44
+ pools: Object.fromEntries(Object.keys(FILTER_DEFAULTS).map((k) => [k, []])),
45
+
46
+ acquire(type) {
47
+ const pool = this.pools[type]
48
+ if (pool && pool.length > 0) {
49
+ return pool.pop()
50
+ }
51
+ return { ...FILTER_DEFAULTS[type] }
52
+ },
53
+
54
+ release(type, obj) {
55
+ if (!obj || !this.pools[type]) return
56
+ const pool = this.pools[type]
57
+ if (pool.length < FILTER_POOL_SIZE) {
58
+ pool.push(obj)
59
+ }
60
+ }
61
+ }
62
+
44
63
  const _utils = Object.freeze({
45
64
  shallowEqual(current, defaults, override, keys) {
46
65
  if (!current) return false
@@ -73,6 +92,19 @@ const _utils = Object.freeze({
73
92
  const out = new Array(len)
74
93
  for (let i = 0; i < len; i++) out[i] = { band: i, gain }
75
94
  return out
95
+ },
96
+
97
+ mutateFilter(target, defaults, options, keys) {
98
+ let changed = false
99
+ for (let i = 0; i < keys.length; i++) {
100
+ const k = keys[i]
101
+ const newVal = k in options ? options[k] : defaults[k]
102
+ if (target[k] !== newVal) {
103
+ target[k] = newVal
104
+ changed = true
105
+ }
106
+ }
107
+ return changed
76
108
  }
77
109
  })
78
110
 
@@ -106,6 +138,11 @@ class Filters {
106
138
  }
107
139
 
108
140
  destroy() {
141
+ for (const [key, value] of Object.entries(this.filters)) {
142
+ if (value && typeof value === 'object' && key !== 'equalizer') {
143
+ filterPool.release(key, value)
144
+ }
145
+ }
109
146
  this._pendingUpdate = false
110
147
  this.player = null
111
148
  }
@@ -114,16 +151,27 @@ class Filters {
114
151
  const current = this.filters[filterName]
115
152
  if (!enabled) {
116
153
  if (current === null) return this
154
+ filterPool.release(filterName, current)
117
155
  this.filters[filterName] = null
156
+ this._dirty.add(filterName)
118
157
  return this._scheduleUpdate()
119
158
  }
120
159
 
121
160
  const defaults = FILTER_DEFAULTS[filterName]
122
161
  const keys = FILTER_KEYS[filterName]
123
- if (current && _utils.shallowEqual(current, defaults, options, keys))
124
- return this
125
162
 
126
- this.filters[filterName] = Object.assign({}, defaults, options)
163
+ if (current) {
164
+ if (_utils.shallowEqual(current, defaults, options, keys)) {
165
+ return this
166
+ }
167
+ _utils.mutateFilter(current, defaults, options, keys)
168
+ this._dirty.add(filterName)
169
+ return this._scheduleUpdate()
170
+ }
171
+
172
+ const newFilter = filterPool.acquire(filterName)
173
+ _utils.mutateFilter(newFilter, defaults, options, keys)
174
+ this.filters[filterName] = newFilter
127
175
  this._dirty.add(filterName)
128
176
  return this._scheduleUpdate()
129
177
  }
@@ -1,5 +1,3 @@
1
- 'use strict'
2
-
3
1
  const IS_BUN = !!(process?.isBun || process?.versions?.bun || globalThis.Bun)
4
2
  if (process && typeof process.isBun !== 'boolean') process.isBun = IS_BUN
5
3
 
@@ -189,6 +187,10 @@ class Node {
189
187
  this._isConnecting = false
190
188
  this.reconnectAttempted = 0
191
189
  this._emitDebug('WebSocket connection established')
190
+ this.aqua?._trace?.('node.ws.open', {
191
+ node: this.name,
192
+ reconnectAttempted: this.reconnectAttempted
193
+ })
192
194
 
193
195
  if (!this.aqua?.bypassChecks?.nodeFetchInfo && !this.info) {
194
196
  const timeoutId = setTimeout(() => {
@@ -263,7 +265,6 @@ class Node {
263
265
 
264
266
  _handleClose(code, reason) {
265
267
  this.connected = false
266
- const wasReady = this.state === NODE_STATE.READY
267
268
  this.state = this.isDestroyed ? NODE_STATE.IDLE : NODE_STATE.RECONNECTING
268
269
  this._isConnecting = false
269
270
 
@@ -271,6 +272,12 @@ class Node {
271
272
  code,
272
273
  reason: _functions.reasonToString(reason)
273
274
  })
275
+ this.aqua?._trace?.('node.ws.close', {
276
+ node: this.name,
277
+ code,
278
+ reason: _functions.reasonToString(reason),
279
+ hasSessionId: !!this.sessionId
280
+ })
274
281
 
275
282
  if (this.isDestroyed) return
276
283
 
@@ -304,6 +311,11 @@ class Node {
304
311
  this._clearReconnectTimeout()
305
312
 
306
313
  const attempt = ++this.reconnectAttempted
314
+ this.aqua?._trace?.('node.ws.reconnect.scheduled', {
315
+ node: this.name,
316
+ attempt,
317
+ infinite: !!this.infiniteReconnects
318
+ })
307
319
 
308
320
  if (this.infiniteReconnects) {
309
321
  this.aqua.emit(AqualinkEvents.NodeReconnect, this, {
@@ -342,8 +354,7 @@ class Node {
342
354
 
343
355
  _calcBackoff(attempt) {
344
356
  const baseBackoff =
345
- this.reconnectTimeout *
346
- Math.pow(Node.BACKOFF_MULTIPLIER, Math.min(attempt, 10))
357
+ this.reconnectTimeout * Node.BACKOFF_MULTIPLIER ** Math.min(attempt, 10)
347
358
  const maxJitter = Math.min(
348
359
  Node.JITTER_MAX,
349
360
  baseBackoff * Node.JITTER_FACTOR
@@ -373,6 +384,10 @@ class Node {
373
384
  this._isConnecting = true
374
385
  this.state = NODE_STATE.CONNECTING
375
386
  this._cleanup()
387
+ this.aqua?._trace?.('node.ws.connect', {
388
+ node: this.name,
389
+ url: this.wsUrl
390
+ })
376
391
 
377
392
  try {
378
393
  const h = this._boundHandlers
@@ -573,6 +588,12 @@ class Node {
573
588
  oldSessionId && oldSessionId !== sessionId && !payload.resumed
574
589
 
575
590
  this.sessionId = sessionId
591
+ this.aqua?._trace?.('node.ready.packet', {
592
+ node: this.name,
593
+ resumed: !!payload.resumed,
594
+ oldSessionId,
595
+ newSessionId: sessionId
596
+ })
576
597
  this.rest.setSessionId(sessionId)
577
598
  this._headers['Session-Id'] = sessionId
578
599
 
@@ -614,6 +635,11 @@ class Node {
614
635
 
615
636
  async _resumePlayers() {
616
637
  if (!this.sessionId) return
638
+ this.aqua?._trace?.('node.resume.begin', {
639
+ node: this.name,
640
+ sessionId: this.sessionId,
641
+ players: this.aqua?.players?.size || 0
642
+ })
617
643
 
618
644
  try {
619
645
  await this.rest.makeRequest('PATCH', `/v4/sessions/${this.sessionId}`, {
@@ -629,6 +655,11 @@ class Node {
629
655
  ) {
630
656
  try {
631
657
  this._emitDebug(`Rejoining voice for guild ${guildId} on resume`)
658
+ this.aqua?._trace?.('node.resume.rejoin', {
659
+ node: this.name,
660
+ guildId,
661
+ voiceChannel: player.voiceChannel
662
+ })
632
663
  player.connect({
633
664
  voiceChannel: player.voiceChannel,
634
665
  deaf: player.deaf,
@@ -647,6 +678,10 @@ class Node {
647
678
  await this.aqua.loadPlayers()
648
679
  }
649
680
  } catch (err) {
681
+ this.aqua?._trace?.('node.resume.error', {
682
+ node: this.name,
683
+ error: _functions.errMsg(err)
684
+ })
650
685
  this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
651
686
  throw err
652
687
  }