aqualink 2.14.0 → 2.15.1

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.
@@ -2,13 +2,14 @@
2
2
 
3
3
  const fs = require('node:fs')
4
4
  const readline = require('node:readline')
5
- const { EventEmitter } = require('tseep')
6
- const { AqualinkEvents } = require('./AqualinkEvents')
5
+ const {EventEmitter} = require('tseep')
6
+ const {AqualinkEvents} = require('./AqualinkEvents')
7
7
  const Node = require('./Node')
8
8
  const Player = require('./Player')
9
9
  const Track = require('./Track')
10
- const { version: pkgVersion } = require('../../package.json')
10
+ const {version: pkgVersion} = require('../../package.json')
11
11
 
12
+ // Constants
12
13
  const SEARCH_PREFIX = ':'
13
14
  const EMPTY_ARRAY = Object.freeze([])
14
15
  const EMPTY_TRACKS_RESPONSE = Object.freeze({
@@ -30,6 +31,10 @@ const NODE_TIMEOUT = 30000
30
31
  const MAX_CACHE_SIZE = 20
31
32
  const MAX_BROKEN_PLAYERS = 50
32
33
  const MAX_FAILOVER_QUEUE = 50
34
+ const MAX_REBUILD_LOCKS = 100
35
+ const WRITE_BUFFER_SIZE = 100
36
+ const MAX_QUEUE_SAVE = 10
37
+ const MAX_TRACKS_RESTORE = 20
33
38
  const URL_PATTERN = /^https?:\/\//i
34
39
 
35
40
  const DEFAULT_OPTIONS = Object.freeze({
@@ -53,13 +58,18 @@ const DEFAULT_OPTIONS = Object.freeze({
53
58
  })
54
59
  })
55
60
 
61
+ // Utility functions (moved to module scope for reuse)
56
62
  const _delay = ms => new Promise(r => setTimeout(r, ms))
57
- const _noop = () => { }
63
+ const _noop = () => {}
58
64
  const _isUrl = query => typeof query === 'string' && query.length > 8 && URL_PATTERN.test(query)
59
65
  const _formatQuery = (query, source) => _isUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}`
60
66
  const _makeTrack = (t, requester, node) => new Track(t, requester, node)
61
- const _safeCall = fn => { try { fn() } catch { } }
62
- const _safeCallAsync = async fn => { try { await fn() } catch { } }
67
+ const _safeCall = fn => {
68
+ try {
69
+ const result = fn()
70
+ return result?.then ? result.catch(_noop) : result
71
+ } catch {}
72
+ }
63
73
 
64
74
  class Aqua extends EventEmitter {
65
75
  constructor(client, nodes, options = {}) {
@@ -75,9 +85,9 @@ class Aqua extends EventEmitter {
75
85
  this.initiated = false
76
86
  this.version = pkgVersion
77
87
 
78
- const merged = { ...DEFAULT_OPTIONS, ...options }
88
+ const merged = {...DEFAULT_OPTIONS, ...options}
79
89
  this.options = merged
80
- this.failoverOptions = { ...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions }
90
+ this.failoverOptions = {...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions}
81
91
 
82
92
  this.shouldDeleteMessage = merged.shouldDeleteMessage
83
93
  this.defaultSearchPlatform = merged.defaultSearchPlatform
@@ -101,8 +111,9 @@ class Aqua extends EventEmitter {
101
111
  this._leastUsedNodesCache = null
102
112
  this._leastUsedNodesCacheTime = 0
103
113
  this._nodeLoadCache = new Map()
114
+ this._eventHandlers = null
104
115
 
105
- this._bindEventHandlers()
116
+ if (this.autoResume) this._bindEventHandlers()
106
117
  }
107
118
 
108
119
  _createDefaultSend() {
@@ -118,30 +129,46 @@ class Aqua extends EventEmitter {
118
129
  }
119
130
 
120
131
  _bindEventHandlers() {
121
- if (!this.autoResume) return
122
- const onNodeConnect = async node => {
123
- this._invalidateCache()
124
- await this._rebuildBrokenPlayers(node)
125
- this._performCleanup()
126
- }
127
- const onNodeDisconnect = node => {
128
- this._invalidateCache()
129
- queueMicrotask(() => {
130
- this._storeBrokenPlayers(node)
132
+ // Store handlers for cleanup
133
+ this._eventHandlers = {
134
+ onNodeConnect: async node => {
135
+ this._invalidateCache()
136
+ await this._rebuildBrokenPlayers(node)
131
137
  this._performCleanup()
132
- })
133
- }
134
- const onNodeReady = (node, { resumed }) => {
135
- if (!resumed) return
136
- const batch = []
137
- for (const player of this.players.values()) {
138
- if (player.nodes === node && player.connection) batch.push(player)
138
+ },
139
+ onNodeDisconnect: node => {
140
+ this._invalidateCache()
141
+ queueMicrotask(() => {
142
+ this._storeBrokenPlayers(node)
143
+ this._performCleanup()
144
+ })
145
+ },
146
+ onNodeReady: (node, {resumed}) => {
147
+ if (!resumed) return
148
+ const batch = []
149
+ for (const player of this.players.values()) {
150
+ if (player.nodes === node && player.connection) batch.push(player)
151
+ }
152
+ if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate({resume: true})))
139
153
  }
140
- if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate({ resume: true })))
141
154
  }
142
- this.on(AqualinkEvents.NodeConnect, onNodeConnect)
143
- this.on(AqualinkEvents.NodeDisconnect, onNodeDisconnect)
144
- this.on(AqualinkEvents.NodeReady, onNodeReady)
155
+ this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
156
+ this.on(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
157
+ this.on(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
158
+ }
159
+
160
+ // Cleanup event handlers to prevent memory leaks
161
+ destroy() {
162
+ if (this._eventHandlers) {
163
+ this.off(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
164
+ this.off(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
165
+ this.off(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
166
+ this._eventHandlers = null
167
+ }
168
+ this.removeAllListeners()
169
+ this.nodeMap.forEach(node => this._destroyNode(node.name || node.host))
170
+ this.players.forEach(player => _safeCall(() => player.destroy()))
171
+ this.players.clear()
145
172
  }
146
173
 
147
174
  get leastUsedNodes() {
@@ -149,10 +176,7 @@ class Aqua extends EventEmitter {
149
176
  if (this._leastUsedNodesCache && (now - this._leastUsedNodesCacheTime) < CACHE_VALID_TIME) {
150
177
  return this._leastUsedNodesCache
151
178
  }
152
- const connected = []
153
- for (const node of this.nodeMap.values()) {
154
- if (node.connected) connected.push(node)
155
- }
179
+ const connected = Array.from(this.nodeMap.values()).filter(n => n.connected)
156
180
  const sorted = this.loadBalancer === 'leastRest'
157
181
  ? connected.sort((a, b) => (a.rest?.calls || 0) - (b.rest?.calls || 0))
158
182
  : this.loadBalancer === 'random'
@@ -179,7 +203,7 @@ class Aqua extends EventEmitter {
179
203
  (stats.playingPlayers || 0) * 0.75 +
180
204
  (stats.memory ? stats.memory.used / Math.max(1, stats.memory.reservable) : 0) * 40 +
181
205
  (node.rest?.calls || 0) * 0.001
182
- this._nodeLoadCache.set(id, { load, time: now })
206
+ this._nodeLoadCache.set(id, {load, time: now})
183
207
  if (this._nodeLoadCache.size > MAX_CACHE_SIZE) {
184
208
  const first = this._nodeLoadCache.keys().next().value
185
209
  this._nodeLoadCache.delete(first)
@@ -192,14 +216,11 @@ class Aqua extends EventEmitter {
192
216
  this.clientId = clientId
193
217
  if (!this.clientId) return
194
218
  const results = await Promise.allSettled(
195
- this.nodes.map(n => Promise.race([this._createNode(n), _delay(NODE_TIMEOUT).then(() => { throw new Error('Timeout') })]))
219
+ this.nodes.map(n => Promise.race([this._createNode(n), _delay(NODE_TIMEOUT).then(() => {throw new Error('Timeout')})]))
196
220
  )
197
221
  if (!results.some(r => r.status === 'fulfilled')) throw new Error('No nodes connected')
198
222
  if (this.plugins?.length) {
199
- await Promise.allSettled(this.plugins.map(p => {
200
- try { return p.load(this) }
201
- catch (err) { this.emit(AqualinkEvents.Error, null, err) }
202
- }))
223
+ await Promise.allSettled(this.plugins.map(p => _safeCall(() => p.load(this))))
203
224
  }
204
225
  this.initiated = true
205
226
  return this
@@ -211,10 +232,10 @@ class Aqua extends EventEmitter {
211
232
  const node = new Node(this, options, this.options)
212
233
  node.players = new Set()
213
234
  this.nodeMap.set(id, node)
214
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
235
+ this._nodeStates.set(id, {connected: false, failoverInProgress: false})
215
236
  try {
216
237
  await node.connect()
217
- this._nodeStates.set(id, { connected: true, failoverInProgress: false })
238
+ this._nodeStates.set(id, {connected: true, failoverInProgress: false})
218
239
  this._invalidateCache()
219
240
  this.emit(AqualinkEvents.NodeCreate, node)
220
241
  return node
@@ -267,7 +288,7 @@ class Aqua extends EventEmitter {
267
288
  const now = Date.now()
268
289
  for (const [guildId, state] of this._brokenPlayers) {
269
290
  if (state.originalNodeId === id && (now - state.brokenAt) < BROKEN_PLAYER_TTL) {
270
- rebuilds.push({ guildId, state })
291
+ rebuilds.push({guildId, state})
271
292
  }
272
293
  }
273
294
  if (!rebuilds.length) return
@@ -275,7 +296,7 @@ class Aqua extends EventEmitter {
275
296
  for (let i = 0; i < rebuilds.length; i += MAX_CONCURRENT_OPS) {
276
297
  const batch = rebuilds.slice(i, i + MAX_CONCURRENT_OPS)
277
298
  const results = await Promise.allSettled(
278
- batch.map(({ guildId, state }) => this._rebuildPlayer(state, node).then(() => guildId))
299
+ batch.map(({guildId, state}) => this._rebuildPlayer(state, node).then(() => guildId))
279
300
  )
280
301
  results.forEach(r => {
281
302
  if (r.status === 'fulfilled') successes.push(r.value)
@@ -287,7 +308,7 @@ class Aqua extends EventEmitter {
287
308
  }
288
309
 
289
310
  async _rebuildPlayer(state, targetNode) {
290
- const { guildId, textChannel, voiceChannel, current, volume = 65, deaf = true } = state
311
+ const {guildId, textChannel, voiceChannel, current, volume = 65, deaf = true} = state
291
312
  const lockKey = `rebuild_${guildId}`
292
313
  if (this._rebuildLocks.has(lockKey)) return
293
314
  this._rebuildLocks.add(lockKey)
@@ -297,7 +318,7 @@ class Aqua extends EventEmitter {
297
318
  await this.destroyPlayer(guildId)
298
319
  await _delay(RECONNECT_DELAY)
299
320
  }
300
- const player = this.createPlayer(targetNode, { guildId, textChannel, voiceChannel, defaultVolume: volume, deaf })
321
+ const player = this.createPlayer(targetNode, {guildId, textChannel, voiceChannel, defaultVolume: volume, deaf})
301
322
  if (current && player?.queue?.add) {
302
323
  player.queue.add(current)
303
324
  await player.play()
@@ -321,7 +342,7 @@ class Aqua extends EventEmitter {
321
342
  const attempts = this._failoverQueue.get(id) || 0
322
343
  if (attempts >= this.failoverOptions.maxFailoverAttempts) return
323
344
 
324
- this._nodeStates.set(id, { connected: false, failoverInProgress: true })
345
+ this._nodeStates.set(id, {connected: false, failoverInProgress: true})
325
346
  this._lastFailoverAttempt.set(id, now)
326
347
  this._failoverQueue.set(id, attempts + 1)
327
348
 
@@ -329,7 +350,7 @@ class Aqua extends EventEmitter {
329
350
  this.emit(AqualinkEvents.NodeFailover, failedNode)
330
351
  const players = Array.from(failedNode.players || [])
331
352
  if (!players.length) return
332
- const available = this._getAvailableNodes(failedNode)
353
+ const available = Array.from(this.nodeMap.values()).filter(n => n !== failedNode && n.connected)
333
354
  if (!available.length) throw new Error('No failover nodes')
334
355
  const results = await this._migratePlayersOptimized(players, available)
335
356
  const successful = results.filter(r => r.success).length
@@ -340,7 +361,7 @@ class Aqua extends EventEmitter {
340
361
  } catch (error) {
341
362
  this.emit(AqualinkEvents.Error, null, error)
342
363
  } finally {
343
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
364
+ this._nodeStates.set(id, {connected: false, failoverInProgress: false})
344
365
  }
345
366
  }
346
367
 
@@ -364,7 +385,7 @@ class Aqua extends EventEmitter {
364
385
  for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
365
386
  const batch = players.slice(i, i + MAX_CONCURRENT_OPS)
366
387
  const batchResults = await Promise.allSettled(batch.map(p => this._migratePlayer(p, pickNode)))
367
- results.push(...batchResults.map(r => ({ success: r.status === 'fulfilled', error: r.reason })))
388
+ results.push(...batchResults.map(r => ({success: r.status === 'fulfilled', error: r.reason})))
368
389
  }
369
390
  return results
370
391
  }
@@ -375,7 +396,7 @@ class Aqua extends EventEmitter {
375
396
  for (let retry = 0; retry < this.failoverOptions.maxRetries; retry++) {
376
397
  try {
377
398
  const targetNode = pickNode()
378
- const newPlayer = await this._createPlayerOnNode(targetNode, state)
399
+ const newPlayer = this._createPlayerOnNode(targetNode, state)
379
400
  await this._restorePlayerState(newPlayer, state)
380
401
  this.emit(AqualinkEvents.PlayerMigrated, player, newPlayer, targetNode)
381
402
  return newPlayer
@@ -421,18 +442,18 @@ class Aqua extends EventEmitter {
421
442
  }
422
443
  if (state.queue?.length && newPlayer.queue?.add) newPlayer.queue.add(...state.queue)
423
444
  if (state.current && this.failoverOptions.preservePosition) {
424
- newPlayer.queue?.add?.(state.current, { toFront: true })
445
+ newPlayer.queue?.add?.(state.current, {toFront: true})
425
446
  if (this.failoverOptions.resumePlayback) {
426
447
  ops.push(newPlayer.play())
427
448
  if (state.position > 0) setTimeout(() => newPlayer.seek?.(state.position), SEEK_DELAY)
428
449
  if (state.paused) ops.push(newPlayer.pause(true))
429
450
  }
430
451
  }
431
- Object.assign(newPlayer, { loop: state.loop, shuffle: state.shuffle })
452
+ Object.assign(newPlayer, {loop: state.loop, shuffle: state.shuffle})
432
453
  await Promise.allSettled(ops)
433
454
  }
434
455
 
435
- updateVoiceState({ d, t }) {
456
+ updateVoiceState({d, t}) {
436
457
  if (!d?.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')) return
437
458
  const player = this.players.get(d.guild_id)
438
459
  if (!player) return
@@ -454,10 +475,7 @@ class Aqua extends EventEmitter {
454
475
  fetchRegion(region) {
455
476
  if (!region) return this.leastUsedNodes
456
477
  const lower = region.toLowerCase()
457
- const filtered = []
458
- for (const node of this.nodeMap.values()) {
459
- if (node.connected && node.regions?.includes(lower)) filtered.push(node)
460
- }
478
+ const filtered = Array.from(this.nodeMap.values()).filter(n => n.connected && n.regions?.includes(lower))
461
479
  return Object.freeze(filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b)))
462
480
  }
463
481
 
@@ -498,10 +516,10 @@ class Aqua extends EventEmitter {
498
516
  if (!player) return
499
517
  this.players.delete(guildId)
500
518
  _safeCall(() => player.removeAllListeners())
501
- await _safeCallAsync(async () => await player.destroy())
519
+ await _safeCall(() => player.destroy())
502
520
  }
503
521
 
504
- async resolve({ query, source = this.defaultSearchPlatform, requester, nodes }) {
522
+ async resolve({query, source = this.defaultSearchPlatform, requester, nodes}) {
505
523
  if (!this.initiated) throw new Error('Aqua not initialized')
506
524
  const node = this._getRequestNode(nodes)
507
525
  if (!node) throw new Error('No nodes available')
@@ -541,8 +559,8 @@ class Aqua extends EventEmitter {
541
559
  }
542
560
 
543
561
  _constructResponse(response, requester, node) {
544
- const { loadType, data, pluginInfo: rootPlugin } = response || {}
545
- const base = { loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: [] }
562
+ const {loadType, data, pluginInfo: rootPlugin} = response || {}
563
+ const base = {loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: []}
546
564
  if (loadType === 'error' || loadType === 'LOAD_FAILED') {
547
565
  base.exception = data || response.exception || null
548
566
  return base
@@ -558,7 +576,7 @@ class Aqua extends EventEmitter {
558
576
  if (data) {
559
577
  const info = data.info || null
560
578
  const thumbnail = data.pluginInfo?.artworkUrl || data.tracks?.[0]?.info?.artworkUrl || null
561
- if (info) base.playlistInfo = { name: info.name || info.title, thumbnail, ...info }
579
+ if (info) base.playlistInfo = {name: info.name || info.title, thumbnail, ...info}
562
580
  base.pluginInfo = data.pluginInfo || base.pluginInfo
563
581
  base.tracks = Array.isArray(data.tracks) ? data.tracks.map(t => _makeTrack(t, requester, node)) : []
564
582
  }
@@ -579,43 +597,26 @@ class Aqua extends EventEmitter {
579
597
  async search(query, requester, source = this.defaultSearchPlatform) {
580
598
  if (!query || !requester) return null
581
599
  try {
582
- const { tracks } = await this.resolve({ query, source: source || this.defaultSearchPlatform, requester })
600
+ const {tracks} = await this.resolve({query, source: source || this.defaultSearchPlatform, requester})
583
601
  return tracks || null
584
- } catch { return null }
585
- }
586
-
587
- async loadPlayers(filePath = './AquaPlayers.jsonl') {
588
- const lockFile = `${filePath}.lock`
589
- try {
590
- await fs.promises.access(filePath).catch(_noop)
591
- await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' }).catch(_noop)
592
- await this._waitForFirstNode()
593
- const stream = fs.createReadStream(filePath, { encoding: 'utf8' })
594
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
595
- const batch = []
596
- for await (const line of rl) {
597
- if (!line.trim()) continue
598
- try { batch.push(JSON.parse(line)) } catch { continue }
599
- if (batch.length >= PLAYER_BATCH_SIZE) {
600
- await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
601
- batch.length = 0
602
- }
603
- }
604
- if (batch.length) await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
605
- await fs.promises.writeFile(filePath, '')
606
- } catch { } finally {
607
- await fs.promises.unlink(lockFile).catch(_noop)
602
+ } catch {
603
+ return null
608
604
  }
609
605
  }
610
606
 
611
607
  async savePlayer(filePath = './AquaPlayers.jsonl') {
612
608
  const lockFile = `${filePath}.lock`
609
+ const tempFile = `${filePath}.tmp`
610
+ let ws = null
613
611
  try {
614
- await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' }).catch(_noop)
615
- const ws = fs.createWriteStream(filePath, { encoding: 'utf8', flags: 'w' })
612
+ await fs.promises.writeFile(lockFile, String(process.pid), {flag: 'wx'})
613
+ ws = fs.createWriteStream(tempFile, {encoding: 'utf8', flags: 'w'})
616
614
  const buffer = []
615
+ let writePromise = Promise.resolve()
616
+
617
617
  for (const player of this.players.values()) {
618
618
  const requester = player.requester || player.current?.requester
619
+ console.log(`Saving player for guild ${player.guildId} to ${player}`)
619
620
  const data = {
620
621
  g: player.guildId,
621
622
  t: player.textChannel,
@@ -623,28 +624,84 @@ class Aqua extends EventEmitter {
623
624
  u: player.current?.uri || null,
624
625
  p: player.position || 0,
625
626
  ts: player.timestamp || 0,
626
- q: player.queue.slice(0, 10).map(tr => tr.uri),
627
- r: requester ? JSON.stringify({ id: requester.id, username: requester.username }) : null,
627
+ q: player.queue.slice(0, MAX_QUEUE_SAVE).map(tr => tr.uri),
628
+ r: requester ? `${requester.id}:${requester.username}` : null,
628
629
  vol: player.volume,
629
630
  pa: player.paused,
630
631
  pl: player.playing,
631
- nw: player.nowPlayingMessage?.id || null
632
+ nw: player.nowPlayingMessage?.id || null,
633
+ resuming: true
632
634
  }
633
635
  buffer.push(JSON.stringify(data))
634
- if (buffer.length >= 100) {
635
- ws.write(buffer.join('\n') + '\n')
636
+
637
+ if (buffer.length >= WRITE_BUFFER_SIZE) {
638
+ const chunk = buffer.join('\n') + '\n'
636
639
  buffer.length = 0
640
+ if (!ws.write(chunk)) {
641
+ writePromise = writePromise.then(() => new Promise(resolve => ws.once('drain', resolve)))
642
+ }
637
643
  }
638
644
  }
639
- if (buffer.length) ws.write(buffer.join('\n') + '\n')
640
- await new Promise(resolve => ws.end(resolve))
645
+
646
+ if (buffer.length) {
647
+ const chunk = buffer.join('\n') + '\n'
648
+ ws.write(chunk)
649
+ }
650
+
651
+ await writePromise
652
+ await new Promise((resolve, reject) => {
653
+ ws.end(err => err ? reject(err) : resolve())
654
+ })
655
+ ws = null
656
+
657
+ await fs.promises.rename(tempFile, filePath)
641
658
  } catch (error) {
642
659
  this.emit(AqualinkEvents.Error, null, error)
660
+ if (ws) _safeCall(() => ws.destroy())
661
+ await fs.promises.unlink(tempFile).catch(_noop)
643
662
  } finally {
644
663
  await fs.promises.unlink(lockFile).catch(_noop)
645
664
  }
646
665
  }
647
666
 
667
+ async loadPlayers(filePath = './AquaPlayers.jsonl') {
668
+ const lockFile = `${filePath}.lock`
669
+ let stream = null
670
+ let rl = null
671
+ try {
672
+ await fs.promises.access(filePath)
673
+ await fs.promises.writeFile(lockFile, String(process.pid), {flag: 'wx'})
674
+ await this._waitForFirstNode()
675
+
676
+ stream = fs.createReadStream(filePath, {encoding: 'utf8'})
677
+ rl = readline.createInterface({input: stream, crlfDelay: Infinity})
678
+
679
+ const batch = []
680
+ for await (const line of rl) {
681
+ if (!line.trim()) continue
682
+ try {
683
+ batch.push(JSON.parse(line))
684
+ } catch {
685
+ continue
686
+ }
687
+ if (batch.length >= PLAYER_BATCH_SIZE) {
688
+ await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
689
+ batch.length = 0
690
+ }
691
+ }
692
+ if (batch.length) await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
693
+
694
+ // Clear file after successful load
695
+ await fs.promises.writeFile(filePath, '')
696
+ } catch (err) {
697
+ if (err.code !== 'ENOENT') this.emit(AqualinkEvents.Error, null, err)
698
+ } finally {
699
+ if (rl) _safeCall(() => rl.close())
700
+ if (stream) _safeCall(() => stream.destroy())
701
+ await fs.promises.unlink(lockFile).catch(_noop)
702
+ }
703
+ }
704
+
648
705
  async _restorePlayer(p) {
649
706
  try {
650
707
  const player = this.players.get(p.g) || this.createPlayer(this._chooseLeastBusyNode(this.leastUsedNodes), {
@@ -652,14 +709,19 @@ class Aqua extends EventEmitter {
652
709
  textChannel: p.t,
653
710
  voiceChannel: p.v,
654
711
  defaultVolume: p.vol || 65,
655
- deaf: true
712
+ deaf: true,
713
+ // indicate this player is being restored from persisted state so Connection
714
+ // can behave accordingly (attempt resume even if voice data is stale)
715
+ resuming: !!p.resuming
656
716
  })
717
+ // Ensure flag is set on existing players as well
718
+ player._resuming = !!p.resuming
657
719
  const requester = this._parseRequester(p.r)
658
- const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0, 20)
659
- const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({ query: uri, requester }).catch(() => null)))
720
+ const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0, MAX_TRACKS_RESTORE)
721
+ const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({query: uri, requester}).catch(() => null)))
660
722
  const validTracks = resolved.flatMap(r => r?.tracks || [])
661
723
  if (validTracks.length && player.queue?.add) {
662
- if (player.queue.length <= 2) player.queue.length = 0;
724
+ if (player.queue.length <= 2) player.queue.length = 0
663
725
  player.queue.add(...validTracks)
664
726
  }
665
727
  if (p.u && validTracks[0]) {
@@ -677,16 +739,13 @@ class Aqua extends EventEmitter {
677
739
  player.nowPlayingMessage = await channel.messages.fetch(p.nw).catch(() => null)
678
740
  }
679
741
  }
680
- } catch { }
742
+ } catch {}
681
743
  }
682
744
 
683
745
  _parseRequester(str) {
684
746
  if (!str || typeof str !== 'string') return null
685
- try { return JSON.parse(str) }
686
- catch {
687
- const i = str.indexOf(':')
688
- return i > 0 ? { id: str.substring(0, i), username: str.substring(i + 1) } : null
689
- }
747
+ const i = str.indexOf(':')
748
+ return i > 0 ? {id: str.substring(0, i), username: str.substring(i + 1)} : null
690
749
  }
691
750
 
692
751
  async _waitForFirstNode(timeout = NODE_TIMEOUT) {
@@ -714,12 +773,12 @@ class Aqua extends EventEmitter {
714
773
  _performCleanup() {
715
774
  const now = Date.now()
716
775
 
776
+ // Cleanup expired broken players
717
777
  for (const [guildId, state] of this._brokenPlayers) {
718
- if (now - state.brokenAt > BROKEN_PLAYER_TTL) {
719
- this._brokenPlayers.delete(guildId)
720
- }
778
+ if (now - state.brokenAt > BROKEN_PLAYER_TTL) this._brokenPlayers.delete(guildId)
721
779
  }
722
780
 
781
+ // Cleanup old failover attempts
723
782
  for (const [id, ts] of this._lastFailoverAttempt) {
724
783
  if (now - ts > FAILOVER_CLEANUP_TTL) {
725
784
  this._lastFailoverAttempt.delete(id)
@@ -729,27 +788,19 @@ class Aqua extends EventEmitter {
729
788
 
730
789
  this._trimBrokenPlayers()
731
790
  if (this._failoverQueue.size > MAX_FAILOVER_QUEUE) this._failoverQueue.clear()
732
- if (this._rebuildLocks.size > 100) this._rebuildLocks.clear()
791
+ if (this._rebuildLocks.size > MAX_REBUILD_LOCKS) this._rebuildLocks.clear()
733
792
 
734
- for (const [id] of this._nodeStates) {
793
+ // Cleanup orphaned node states
794
+ for (const id of this._nodeStates.keys()) {
735
795
  if (!this.nodeMap.has(id)) this._nodeStates.delete(id)
736
796
  }
737
797
  }
738
798
 
739
-
740
799
  _trimBrokenPlayers() {
741
800
  if (this._brokenPlayers.size <= MAX_BROKEN_PLAYERS) return
742
801
  const sorted = [...this._brokenPlayers.entries()].sort((a, b) => a[1].brokenAt - b[1].brokenAt)
743
802
  sorted.slice(0, sorted.length - MAX_BROKEN_PLAYERS).forEach(([id]) => this._brokenPlayers.delete(id))
744
803
  }
745
-
746
- _getAvailableNodes(excludeNode) {
747
- const nodes = []
748
- for (const node of this.nodeMap.values()) {
749
- if (node !== excludeNode && node.connected) nodes.push(node)
750
- }
751
- return nodes
752
- }
753
804
  }
754
805
 
755
806
  module.exports = Aqua
@@ -125,10 +125,18 @@ class Connection {
125
125
  }
126
126
 
127
127
  _canAttemptResume () {
128
- return !this._destroyed &&
129
- this._hasValidVoiceData() &&
130
- this._reconnectAttempts < MAX_RECONNECT_ATTEMPTS &&
131
- !(this._stateFlags & (STATE_FLAGS.ATTEMPTING_RESUME | STATE_FLAGS.DISCONNECTING | STATE_FLAGS.VOICE_DATA_STALE))
128
+ // Allow resume if connection isn't destroyed, attempts remain, and we're
129
+ // not already attempting or disconnecting. If voice data is stale, permit
130
+ // a resume attempt only when the player was restored from persisted state
131
+ // (player._resuming === true).
132
+ if (this._destroyed) return false
133
+ if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return false
134
+ if (this._stateFlags & (STATE_FLAGS.ATTEMPTING_RESUME | STATE_FLAGS.DISCONNECTING)) return false
135
+
136
+ if (this._hasValidVoiceData()) return true
137
+
138
+ // If voice data is stale, allow a single resume flow for restored players
139
+ return !!this._player?._resuming
132
140
  }
133
141
 
134
142
  setServerUpdate (data) {
@@ -236,9 +244,38 @@ class Connection {
236
244
  async attemptResume () {
237
245
  if (!this._canAttemptResume()) {
238
246
  this._aqua.emit(AqualinkEvents.Debug, `Resume blocked: destroyed=${this._destroyed}, hasValidData=${this._hasValidVoiceData()}, attempts=${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`)
239
- if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS || (this._stateFlags & STATE_FLAGS.VOICE_DATA_STALE)) {
247
+
248
+ // If voice data is stale (likely after a restart) and this player is
249
+ // marked as resuming, request a fresh voice state update and schedule
250
+ // a retry instead of destroying the player immediately.
251
+ if ((this._stateFlags & STATE_FLAGS.VOICE_DATA_STALE) && this._player?._resuming) {
252
+ try {
253
+ this._aqua.emit(AqualinkEvents.Debug, `Requesting fresh voice state for guild ${this._guildId}`)
254
+ if (typeof this._player.send === 'function' && this._player.voiceChannel) {
255
+ this._player.send({ guild_id: this._guildId, channel_id: this._player.voiceChannel, self_deaf: this._player.deaf, self_mute: this._player.mute })
256
+ this._reconnectTimer = setTimeout(this._handleReconnect, 1500)
257
+ helpers.safeUnref(this._reconnectTimer)
258
+ }
259
+ } catch (e) {}
260
+ } else if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
240
261
  this._handleDisconnect()
241
262
  }
263
+
264
+ return false
265
+ }
266
+
267
+ // If the player was restored from disk but core voice data is still missing,
268
+ // request a voice state update and retry shortly rather than attempting a
269
+ // resume payload that will fail.
270
+ if ((!this.sessionId || !this.endpoint || !this.token) && this._player?._resuming) {
271
+ try {
272
+ this._aqua.emit(AqualinkEvents.Debug, `Resuming player but voice data missing, requesting voice state for guild ${this._guildId}`)
273
+ if (typeof this._player.send === 'function' && this._player.voiceChannel) {
274
+ this._player.send({ guild_id: this._guildId, channel_id: this._player.voiceChannel, self_deaf: this._player.deaf, self_mute: this._player.mute })
275
+ this._reconnectTimer = setTimeout(this._handleReconnect, 1500)
276
+ helpers.safeUnref(this._reconnectTimer)
277
+ }
278
+ } catch (e) {}
242
279
  return false
243
280
  }
244
281
 
@@ -263,6 +300,7 @@ class Connection {
263
300
 
264
301
  this._reconnectAttempts = 0
265
302
  this._consecutiveFailures = 0
303
+ if (this._player) this._player._resuming = false
266
304
  this._aqua.emit(AqualinkEvents.Debug, `Resume successful for guild ${this._guildId}`)
267
305
  return true
268
306
  } catch (error) {
@@ -275,6 +313,7 @@ class Connection {
275
313
  helpers.safeUnref(this._reconnectTimer)
276
314
  } else {
277
315
  this._aqua.emit(AqualinkEvents.Debug, `Max reconnect attempts or failures reached for guild ${this._guildId}`)
316
+ if (this._player) this._player._resuming = false
278
317
  this._handleDisconnect()
279
318
  }
280
319