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,8 +1,6 @@
1
- 'use strict'
2
-
3
1
  const fs = require('node:fs')
4
2
  const readline = require('node:readline')
5
- const { EventEmitter } = require('tseep')
3
+ const { EventEmitter } = require('node:events')
6
4
  const { AqualinkEvents } = require('./AqualinkEvents')
7
5
  const Node = require('./Node')
8
6
  const Player = require('./Player')
@@ -23,7 +21,6 @@ const MAX_CONCURRENT_OPS = 10
23
21
  const BROKEN_PLAYER_TTL = 300000
24
22
  const FAILOVER_CLEANUP_TTL = 600000
25
23
  const PLAYER_BATCH_SIZE = 20
26
- const SEEK_DELAY = 120
27
24
  const RECONNECT_DELAY = 400
28
25
  const CACHE_VALID_TIME = 12000
29
26
  const NODE_TIMEOUT = 30000
@@ -31,8 +28,7 @@ const MAX_CACHE_SIZE = 20
31
28
  const MAX_FAILOVER_QUEUE = 50
32
29
  const MAX_REBUILD_LOCKS = 100
33
30
  const WRITE_BUFFER_SIZE = 100
34
- const MAX_QUEUE_SAVE = 10
35
- const MAX_TRACKS_RESTORE = 20
31
+ const TRACE_BUFFER_SIZE = 3000
36
32
 
37
33
  const DEFAULT_OPTIONS = Object.freeze({
38
34
  shouldDeleteMessage: false,
@@ -44,6 +40,10 @@ const DEFAULT_OPTIONS = Object.freeze({
44
40
  infiniteReconnects: true,
45
41
  loadBalancer: 'leastLoad',
46
42
  useHttp2: false,
43
+ debugTrace: false,
44
+ traceMaxEntries: TRACE_BUFFER_SIZE,
45
+ traceSink: null,
46
+ autoRegionMigrate: false,
47
47
  failoverOptions: Object.freeze({
48
48
  enabled: true,
49
49
  maxRetries: 3,
@@ -126,10 +126,22 @@ class Aqua extends EventEmitter {
126
126
  this.restrictedDomains = merged.restrictedDomains || []
127
127
  this.allowedDomains = merged.allowedDomains || []
128
128
  this.loadBalancer = merged.loadBalancer
129
+ this.autoRegionMigrate = merged.autoRegionMigrate
129
130
  this.useHttp2 = merged.useHttp2
130
131
  this.maxQueueSave = merged.maxQueueSave
131
132
  this.maxTracksRestore = merged.maxTracksRestore
132
133
  this.send = merged.send || this._createDefaultSend()
134
+ this.debugTrace = !!merged.debugTrace
135
+ this.traceMaxEntries = Math.max(
136
+ 100,
137
+ Number(merged.traceMaxEntries) || TRACE_BUFFER_SIZE
138
+ )
139
+ this.traceSink =
140
+ typeof merged.traceSink === 'function' ? merged.traceSink : null
141
+ this._traceBuffer = new Array(this.traceMaxEntries)
142
+ this._traceBufferCount = 0
143
+ this._traceBufferIndex = 0
144
+ this._traceSeq = 0
133
145
 
134
146
  this._nodeStates = new Map()
135
147
  this._failoverQueue = new Map()
@@ -145,6 +157,49 @@ class Aqua extends EventEmitter {
145
157
  if (this.autoResume) this._bindEventHandlers()
146
158
  }
147
159
 
160
+ _trace(event, data = null) {
161
+ if (!this.debugTrace) return
162
+ const entry = {
163
+ seq: ++this._traceSeq,
164
+ at: Date.now(),
165
+ event,
166
+ data
167
+ }
168
+ if (this._traceBuffer.length !== this.traceMaxEntries) {
169
+ this._traceBuffer = new Array(this.traceMaxEntries)
170
+ this._traceBufferCount = 0
171
+ this._traceBufferIndex = 0
172
+ }
173
+ this._traceBuffer[this._traceBufferIndex] = entry
174
+ this._traceBufferIndex = (this._traceBufferIndex + 1) % this.traceMaxEntries
175
+ if (this._traceBufferCount < this.traceMaxEntries) this._traceBufferCount++
176
+ if (this.traceSink) _functions.safeCall(() => this.traceSink(entry))
177
+ if (this.listenerCount(AqualinkEvents.Debug) > 0) {
178
+ this.emit(AqualinkEvents.Debug, 'trace', JSON.stringify(entry))
179
+ }
180
+ }
181
+
182
+ getTrace(limit = 300) {
183
+ const max = Math.max(1, Number(limit) || 300)
184
+ const count = Math.min(max, this._traceBufferCount)
185
+ if (!count) return []
186
+ const out = new Array(count)
187
+ let start =
188
+ (this._traceBufferIndex - count + this.traceMaxEntries) %
189
+ this.traceMaxEntries
190
+ for (let i = 0; i < count; i++) {
191
+ out[i] = this._traceBuffer[start]
192
+ start = (start + 1) % this.traceMaxEntries
193
+ }
194
+ return out
195
+ }
196
+
197
+ clearTrace() {
198
+ this._traceBuffer.fill(undefined)
199
+ this._traceBufferCount = 0
200
+ this._traceBufferIndex = 0
201
+ }
202
+
148
203
  _createDefaultSend() {
149
204
  return (packet) => {
150
205
  const guildId = packet?.d?.guild_id
@@ -161,12 +216,13 @@ class Aqua extends EventEmitter {
161
216
 
162
217
  _bindEventHandlers() {
163
218
  this._eventHandlers = {
164
- onNodeConnect: async (node) => {
219
+ onNodeConnect: (node) => {
220
+ this._trace('node.connect', { node: node?.name || node?.host })
165
221
  this._invalidateCache()
166
- await this._rebuildBrokenPlayers(node)
167
222
  this._performCleanup()
168
223
  },
169
224
  onNodeDisconnect: (node) => {
225
+ this._trace('node.disconnect', { node: node?.name || node?.host })
170
226
  this._invalidateCache()
171
227
  queueMicrotask(() => {
172
228
  this._storeBrokenPlayers(node)
@@ -174,15 +230,27 @@ class Aqua extends EventEmitter {
174
230
  })
175
231
  },
176
232
  onNodeReady: (node, { resumed }) => {
177
- if (!resumed) return
178
- const batch = []
179
- for (const player of this.players.values()) {
180
- if (player.nodes === node && player.connection) batch.push(player)
233
+ this._trace('node.ready', {
234
+ node: node?.name || node?.host,
235
+ resumed: !!resumed,
236
+ players: this.players.size
237
+ })
238
+ if (resumed) {
239
+ const batch = []
240
+ for (const player of this.players.values()) {
241
+ if (player.nodes === node && player.connection) batch.push(player)
242
+ }
243
+ if (batch.length)
244
+ queueMicrotask(() =>
245
+ batch.forEach((p) => {
246
+ p.connection.resendVoiceUpdate()
247
+ })
248
+ )
249
+ return
181
250
  }
182
- if (batch.length)
183
- queueMicrotask(() =>
184
- batch.forEach((p) => p.connection.resendVoiceUpdate())
185
- )
251
+ queueMicrotask(() => {
252
+ this._rebuildBrokenPlayers(node).catch(_functions.noop)
253
+ })
186
254
  }
187
255
  }
188
256
  this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
@@ -257,7 +325,11 @@ class Aqua extends EventEmitter {
257
325
  const id = node.name || node.host
258
326
  const now = Date.now()
259
327
  const cached = this._nodeLoadCache.get(id)
260
- if (cached && now - cached.time < 5000) return cached.load
328
+ if (cached && now - cached.time < 5000) {
329
+ this._nodeLoadCache.delete(id)
330
+ this._nodeLoadCache.set(id, cached)
331
+ return cached.load
332
+ }
261
333
  const stats = node?.stats
262
334
  if (!stats) return 0
263
335
  const cores = Math.max(1, stats.cpu?.cores || 1)
@@ -267,11 +339,15 @@ class Aqua extends EventEmitter {
267
339
  (stats.playingPlayers || 0) * 0.75 +
268
340
  (stats.memory ? stats.memory.used / reservable : 0) * 40 +
269
341
  (node.rest?.calls || 0) * 0.001
270
- this._nodeLoadCache.set(id, { load, time: now })
271
- if (this._nodeLoadCache.size > MAX_CACHE_SIZE) {
272
- const first = this._nodeLoadCache.keys().next().value
273
- this._nodeLoadCache.delete(first)
342
+ if (this._nodeLoadCache.size >= MAX_CACHE_SIZE) {
343
+ const iterator = this._nodeLoadCache.keys()
344
+ while (this._nodeLoadCache.size >= MAX_CACHE_SIZE) {
345
+ const oldest = iterator.next().value
346
+ if (!oldest) break
347
+ this._nodeLoadCache.delete(oldest)
348
+ }
274
349
  }
350
+ this._nodeLoadCache.set(id, { load, time: now })
275
351
  return load
276
352
  }
277
353
 
@@ -329,9 +405,8 @@ class Aqua extends EventEmitter {
329
405
  _destroyNode(id) {
330
406
  const node = this.nodeMap.get(id)
331
407
  if (!node) return
332
- _functions.safeCall(() => node.destroy())
408
+ _functions.safeCall(() => node.destroy(true))
333
409
  this._cleanupNode(id)
334
- this.emit(AqualinkEvents.NodeDestroy, node)
335
410
  }
336
411
 
337
412
  _cleanupNode(id) {
@@ -420,18 +495,7 @@ class Aqua extends EventEmitter {
420
495
  if (current && player?.queue?.add) {
421
496
  player.queue.add(current)
422
497
  await player.play()
423
- if (state.position > 0) {
424
- const seekOnce = (p) => {
425
- if (p.guildId === guildId) {
426
- this.off(AqualinkEvents.TrackStart, seekOnce)
427
- _functions.unrefTimeout(() => player.seek?.(state.position), 50)
428
- }
429
- }
430
- this.once(AqualinkEvents.TrackStart, seekOnce)
431
- player.once('destroy', () =>
432
- this.off(AqualinkEvents.TrackStart, seekOnce)
433
- )
434
- }
498
+ this._seekAfterTrackStart(player, guildId, state.position, 50)
435
499
  if (state.paused) player.pause(true)
436
500
  }
437
501
  return player
@@ -528,9 +592,111 @@ class Aqua extends EventEmitter {
528
592
  return newPlayer
529
593
  } catch (error) {
530
594
  if (retry === maxRetries - 1) throw error
531
- await _functions.delay(retryDelay * Math.pow(1.5, retry))
595
+ await _functions.delay(retryDelay * 1.5 ** retry)
596
+ }
597
+ }
598
+ }
599
+
600
+ _regionMatches(configuredRegion, extractedRegion) {
601
+ if (!configuredRegion || !extractedRegion) return false
602
+ const configured = String(configuredRegion).trim().toLowerCase()
603
+ const extracted = String(extractedRegion).trim().toLowerCase()
604
+ if (!configured || !extracted) return false
605
+ return configured === extracted
606
+ }
607
+
608
+ _findBestNodeForRegion(region) {
609
+ if (!region) return null
610
+ const candidates = []
611
+ for (const node of this.nodeMap.values()) {
612
+ if (!node?.connected) continue
613
+ const regions = Array.isArray(node.regions) ? node.regions : []
614
+ if (regions.some((r) => this._regionMatches(r, region))) {
615
+ candidates.push(node)
532
616
  }
533
617
  }
618
+ if (!candidates.length) return null
619
+ return this._chooseLeastBusyNode(candidates)
620
+ }
621
+
622
+ async movePlayerToNode(guildId, targetNode, reason = 'region') {
623
+ const id = String(guildId)
624
+ const player = this.players.get(id)
625
+ if (!player || player.destroyed) throw new Error(`Player not found: ${id}`)
626
+ if (!targetNode?.connected) throw new Error('Target node is not connected')
627
+ if (player.nodes === targetNode || player.nodes?.name === targetNode.name)
628
+ return player
629
+
630
+ const state = this._capturePlayerState(player)
631
+ if (!state) throw new Error(`Failed to capture state for ${id}`)
632
+ const oldPlayer = player
633
+ const oldNode = oldPlayer.nodes
634
+ const oldMessage = oldPlayer.nowPlayingMessage || null
635
+ const oldConn = oldPlayer.connection
636
+ const oldVoice = oldConn
637
+ ? {
638
+ sessionId: oldConn.sessionId || null,
639
+ endpoint: oldConn.endpoint || null,
640
+ token: oldConn.token || null,
641
+ region: oldConn.region || null,
642
+ channelId: oldConn.channelId || null
643
+ }
644
+ : null
645
+
646
+ oldPlayer.destroy({
647
+ preserveClient: true,
648
+ skipRemote: true,
649
+ preserveMessage: true,
650
+ preserveTracks: true,
651
+ preserveReconnecting: true
652
+ })
653
+
654
+ const newPlayer = this.createPlayer(targetNode, {
655
+ guildId: state.guildId,
656
+ textChannel: state.textChannel,
657
+ voiceChannel: state.voiceChannel,
658
+ defaultVolume: state.volume || 100,
659
+ deaf: state.deaf || false,
660
+ mute: oldPlayer.mute || false,
661
+ resuming: true,
662
+ preserveMessage: true
663
+ })
664
+
665
+ // Bootstrap voice on the new node using last known voice state to avoid
666
+ // "track queued, waiting for voice state" after region migration.
667
+ if (oldVoice && newPlayer.connection) {
668
+ if (oldVoice.sessionId)
669
+ newPlayer.connection.sessionId = oldVoice.sessionId
670
+ if (oldVoice.endpoint) newPlayer.connection.endpoint = oldVoice.endpoint
671
+ if (oldVoice.token) newPlayer.connection.token = oldVoice.token
672
+ if (oldVoice.region) newPlayer.connection.region = oldVoice.region
673
+ if (oldVoice.channelId)
674
+ newPlayer.connection.channelId = oldVoice.channelId
675
+ newPlayer.connection._lastVoiceDataUpdate = Date.now()
676
+ newPlayer.connection.resendVoiceUpdate(true)
677
+ this._trace('player.migrate.voiceBootstrap', {
678
+ guildId: id,
679
+ from: oldNode?.name || oldNode?.host,
680
+ to: targetNode?.name || targetNode?.host,
681
+ hasSessionId: !!newPlayer.connection.sessionId,
682
+ hasEndpoint: !!newPlayer.connection.endpoint,
683
+ hasToken: !!newPlayer.connection.token
684
+ })
685
+ }
686
+
687
+ await this._restorePlayerState(newPlayer, state)
688
+ if (oldMessage) newPlayer.nowPlayingMessage = oldMessage
689
+
690
+ this._trace('player.migrated', {
691
+ guildId: id,
692
+ reason,
693
+ from: oldNode?.name || oldNode?.host,
694
+ to: targetNode?.name || targetNode?.host,
695
+ region:
696
+ newPlayer?.connection?.region || oldPlayer?.connection?.region || null
697
+ })
698
+ this.emit(AqualinkEvents.PlayerMigrated, oldPlayer, newPlayer, targetNode)
699
+ return newPlayer
534
700
  }
535
701
 
536
702
  _capturePlayerState(player) {
@@ -569,6 +735,16 @@ class Aqua extends EventEmitter {
569
735
  })
570
736
  }
571
737
 
738
+ _seekAfterTrackStart(player, guildId, position, delay = 50) {
739
+ if (!player || !guildId || !(position > 0)) return
740
+ const seekOnce = (p) => {
741
+ if (p.guildId !== guildId) return
742
+ _functions.unrefTimeout(() => player.seek?.(position), delay)
743
+ }
744
+ this.once(AqualinkEvents.TrackStart, seekOnce)
745
+ player.once('destroy', () => this.off(AqualinkEvents.TrackStart, seekOnce))
746
+ }
747
+
572
748
  async _restorePlayerState(newPlayer, state) {
573
749
  const ops = []
574
750
  if (typeof state.volume === 'number') {
@@ -579,26 +755,17 @@ class Aqua extends EventEmitter {
579
755
  if (state.queue?.length && newPlayer.queue?.add)
580
756
  newPlayer.queue.add(...state.queue)
581
757
  if (state.current && this.failoverOptions.preservePosition) {
582
- newPlayer.queue?.add?.(state.current, { toFront: true })
583
758
  if (this.failoverOptions.resumePlayback) {
584
- ops.push(newPlayer.play())
585
- if (state.position > 0) {
586
- const guildId = newPlayer.guildId
587
- const seekOnce = (p) => {
588
- if (p.guildId === guildId) {
589
- this.off(AqualinkEvents.TrackStart, seekOnce)
590
- _functions.unrefTimeout(
591
- () => newPlayer.seek?.(state.position),
592
- 50
593
- )
594
- }
595
- }
596
- this.once(AqualinkEvents.TrackStart, seekOnce)
597
- newPlayer.once('destroy', () =>
598
- this.off(AqualinkEvents.TrackStart, seekOnce)
599
- )
600
- }
759
+ ops.push(newPlayer.play(state.current))
760
+ this._seekAfterTrackStart(
761
+ newPlayer,
762
+ newPlayer.guildId,
763
+ state.position,
764
+ 50
765
+ )
601
766
  if (state.paused) ops.push(newPlayer.pause(true))
767
+ } else if (newPlayer.queue?.add) {
768
+ newPlayer.queue.add(state.current)
602
769
  }
603
770
  }
604
771
  newPlayer.loop = state.loop
@@ -614,6 +781,13 @@ class Aqua extends EventEmitter {
614
781
  return
615
782
  const player = this.players.get(String(d.guild_id))
616
783
  if (!player) return
784
+ this._trace('voice.gateway', {
785
+ guildId: String(d.guild_id),
786
+ type: t,
787
+ hasSessionId: !!d.session_id,
788
+ hasEndpoint: !!d.endpoint,
789
+ hasChannelId: d.channel_id !== undefined
790
+ })
617
791
 
618
792
  d.txId = player.txId
619
793
  if (t === 'VOICE_STATE_UPDATE') {
@@ -676,6 +850,13 @@ class Aqua extends EventEmitter {
676
850
  const player = new Player(this, node, options)
677
851
  const guildId = String(options.guildId)
678
852
  this.players.set(guildId, player)
853
+ this._trace('player.create', {
854
+ guildId,
855
+ node: node?.name || node?.host,
856
+ voiceChannel: options.voiceChannel,
857
+ textChannel: options.textChannel,
858
+ resuming: !!options.resuming
859
+ })
679
860
  node?.players?.add?.(player)
680
861
  player.once('destroy', () => this._handlePlayerDestroy(player))
681
862
  player.connect(options)
@@ -687,6 +868,10 @@ class Aqua extends EventEmitter {
687
868
  player.nodes?.players?.delete?.(player)
688
869
  const guildId = String(player.guildId)
689
870
  if (this.players.get(guildId) === player) this.players.delete(guildId)
871
+ this._trace('player.destroyed', {
872
+ guildId,
873
+ node: player?.nodes?.name || player?.nodes?.host
874
+ })
690
875
  this.emit(AqualinkEvents.PlayerDestroyed, player)
691
876
  }
692
877
 
@@ -694,9 +879,13 @@ class Aqua extends EventEmitter {
694
879
  const id = String(guildId)
695
880
  const player = this.players.get(id)
696
881
  if (!player) return
882
+
883
+ // Guard against recursive destroy calls triggered by Player.destroy().
697
884
  this.players.delete(id)
698
- _functions.safeCall(() => player.removeAllListeners())
699
885
  await _functions.safeCall(() => player.destroy())
886
+
887
+ // Fallback cleanup in case the player "destroy" listener was not attached.
888
+ if (player?.nodes?.players?.has?.(player)) this._handlePlayerDestroy(player)
700
889
  }
701
890
 
702
891
  async resolve({ query, source, requester, nodes }) {
@@ -774,7 +963,10 @@ class Aqua extends EventEmitter {
774
963
  }
775
964
  if (loadType === 'track' && data) {
776
965
  base.pluginInfo =
777
- data.info?.pluginInfo || data.pluginInfo || base.pluginInfo
966
+ data.pluginInfo ||
967
+ data.info?.pluginInfo ||
968
+ rootPlugin ||
969
+ base.pluginInfo
778
970
  base.tracks.push(_functions.makeTrack(data, requester, node))
779
971
  } else if (loadType === 'playlist' && data) {
780
972
  const info = data.info
@@ -858,7 +1050,7 @@ class Aqua extends EventEmitter {
858
1050
  buffer.push(JSON.stringify(data))
859
1051
 
860
1052
  if (buffer.length >= WRITE_BUFFER_SIZE) {
861
- const chunk = buffer.join('\n') + '\n'
1053
+ const chunk = `${buffer.join('\n')}\n`
862
1054
  buffer.length = 0
863
1055
  if (!ws.write(chunk)) {
864
1056
  drainPromise = drainPromise.then(
@@ -868,7 +1060,7 @@ class Aqua extends EventEmitter {
868
1060
  }
869
1061
  }
870
1062
 
871
- if (buffer.length) ws.write(buffer.join('\n') + '\n')
1063
+ if (buffer.length) ws.write(`${buffer.join('\n')}\n`)
872
1064
  await drainPromise
873
1065
  await new Promise((resolve, reject) =>
874
1066
  ws.end((err) => (err ? reject(err) : resolve()))
@@ -935,7 +1127,7 @@ class Aqua extends EventEmitter {
935
1127
  try {
936
1128
  const gId = String(p.g)
937
1129
  const existing = this.players.get(gId)
938
- if (existing && existing.playing) return
1130
+ if (existing?.playing) return
939
1131
 
940
1132
  const player =
941
1133
  existing ||
@@ -968,25 +1160,26 @@ class Aqua extends EventEmitter {
968
1160
  else player.volume = p.vol
969
1161
  }
970
1162
 
971
- if (p.p > 0) {
972
- const seekOnce = (pl) => {
973
- if (pl.guildId === gId) {
974
- this.off(AqualinkEvents.TrackStart, seekOnce)
975
- _functions.unrefTimeout(() => player.seek?.(p.p), 100)
976
- }
977
- }
978
- this.on(AqualinkEvents.TrackStart, seekOnce)
979
- player.once('destroy', () => this.off(AqualinkEvents.TrackStart, seekOnce))
980
- }
1163
+ this._seekAfterTrackStart(player, gId, p.p, 100)
981
1164
 
982
1165
  await player.play(undefined, { startTime: p.p, paused: p.pa })
983
1166
  }
984
1167
  if (p.nw && p.t) {
985
- const channel = this.client.channels?.cache?.get(p.t)
986
- if (channel?.messages)
1168
+ const channel = this.client.channels?.cache?.get?.(p.t)
1169
+ if (channel?.messages?.fetch) {
987
1170
  player.nowPlayingMessage = await channel.messages
988
1171
  .fetch(p.nw)
989
1172
  .catch(() => null)
1173
+ } else if (this.client.messages?.fetch) {
1174
+ player.nowPlayingMessage = await this.client.messages
1175
+ .fetch(p.nw, p.t)
1176
+ .catch(() => null)
1177
+ }
1178
+ this._trace('player.nowPlaying.restore', {
1179
+ guildId: gId,
1180
+ messageId: p.nw,
1181
+ restored: !!player.nowPlayingMessage
1182
+ })
990
1183
  }
991
1184
  } catch (e) {
992
1185
  console.error(
@@ -1067,9 +1260,7 @@ class Aqua extends EventEmitter {
1067
1260
  }
1068
1261
  break
1069
1262
  }
1070
- } catch {
1071
- continue
1072
- }
1263
+ } catch {}
1073
1264
  }
1074
1265
  } catch {
1075
1266
  } finally {
@@ -1,5 +1,3 @@
1
- 'use strict'
2
-
3
1
  const AqualinkEvents = {
4
2
  TrackStart: 'trackStart',
5
3
  TrackEnd: 'trackEnd',