aqualink 2.20.1 → 3.1.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,10 +1,13 @@
1
- const fs = require('node:fs')
2
- const readline = require('node:readline')
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const _readline = require('node:readline')
3
4
  const { EventEmitter } = require('node:events')
4
5
  const { AqualinkEvents } = require('./AqualinkEvents')
6
+ const AquaRecovery = require('./AquaRecovery')
5
7
  const Node = require('./Node')
6
8
  const Player = require('./Player')
7
9
  const Track = require('./Track')
10
+ const { reportSuppressedError } = require('./Reporting')
8
11
  const { version: pkgVersion } = require('../../package.json')
9
12
 
10
13
  const SEARCH_PREFIX = ':'
@@ -29,6 +32,7 @@ const MAX_FAILOVER_QUEUE = 50
29
32
  const MAX_REBUILD_LOCKS = 100
30
33
  const WRITE_BUFFER_SIZE = 100
31
34
  const TRACE_BUFFER_SIZE = 3000
35
+ const VOICE_STATE_QUEUE_INTERVAL = 900
32
36
 
33
37
  const DEFAULT_OPTIONS = Object.freeze({
34
38
  shouldDeleteMessage: false,
@@ -54,7 +58,9 @@ const DEFAULT_OPTIONS = Object.freeze({
54
58
  maxFailoverAttempts: 5
55
59
  }),
56
60
  maxQueueSave: 10,
57
- maxTracksRestore: 20
61
+ maxTracksRestore: 20,
62
+ trackResolveConcurrency: 4,
63
+ brokenPlayerStorePath: null
58
64
  })
59
65
 
60
66
  const _functions = {
@@ -130,6 +136,15 @@ class Aqua extends EventEmitter {
130
136
  this.useHttp2 = merged.useHttp2
131
137
  this.maxQueueSave = merged.maxQueueSave
132
138
  this.maxTracksRestore = merged.maxTracksRestore
139
+ this.trackResolveConcurrency = Math.max(
140
+ 1,
141
+ Number(merged.trackResolveConcurrency) || 4
142
+ )
143
+ this.brokenPlayerStorePath =
144
+ typeof merged.brokenPlayerStorePath === 'string' &&
145
+ merged.brokenPlayerStorePath.trim()
146
+ ? merged.brokenPlayerStorePath
147
+ : path.join(process.cwd(), `AquaBrokenPlayers.${process.pid}.jsonl`)
133
148
  this.send = merged.send || this._createDefaultSend()
134
149
  this.debugTrace = !!merged.debugTrace
135
150
  this.traceMaxEntries = Math.max(
@@ -138,14 +153,13 @@ class Aqua extends EventEmitter {
138
153
  )
139
154
  this.traceSink =
140
155
  typeof merged.traceSink === 'function' ? merged.traceSink : null
141
- this._traceBuffer = new Array(this.traceMaxEntries)
156
+ this._traceBuffer = this.debugTrace ? new Array(this.traceMaxEntries) : null
142
157
  this._traceBufferCount = 0
143
158
  this._traceBufferIndex = 0
144
159
  this._traceSeq = 0
145
160
 
146
- this._nodeStates = new Map()
147
- this._failoverQueue = new Map()
148
- this._lastFailoverAttempt = new Map()
161
+ this._failoverState = Object.create(null)
162
+ this._guildLifecycleLocks = new Map()
149
163
  this._brokenPlayers = new Map()
150
164
  this._rebuildLocks = new Set()
151
165
  this._leastUsedNodesCache = null
@@ -153,22 +167,44 @@ class Aqua extends EventEmitter {
153
167
  this._nodeLoadCache = new Map()
154
168
  this._eventHandlers = null
155
169
  this._loading = false
170
+ this._voiceStateQueue = []
171
+ this._voiceStateQueueHead = 0
172
+ this._voiceStateQueued = new Set()
173
+ this._voiceStatePending = new Map()
174
+ this._voiceStateFlushTimer = null
175
+ this._lastVoiceStateSendAt = 0
176
+ this._recovery = new AquaRecovery(this, {
177
+ _functions,
178
+ MAX_CONCURRENT_OPS,
179
+ BROKEN_PLAYER_TTL,
180
+ FAILOVER_CLEANUP_TTL,
181
+ MAX_FAILOVER_QUEUE,
182
+ MAX_REBUILD_LOCKS,
183
+ PLAYER_BATCH_SIZE,
184
+ RECONNECT_DELAY,
185
+ NODE_TIMEOUT,
186
+ EMPTY_ARRAY
187
+ })
156
188
 
157
189
  if (this.autoResume) this._bindEventHandlers()
158
190
  }
159
191
 
160
192
  _trace(event, data = null) {
161
193
  if (!this.debugTrace) return
194
+ if (
195
+ !this._traceBuffer ||
196
+ this._traceBuffer.length !== this.traceMaxEntries
197
+ ) {
198
+ this._traceBuffer = new Array(this.traceMaxEntries)
199
+ this._traceBufferCount = 0
200
+ this._traceBufferIndex = 0
201
+ }
202
+ const resolvedData = typeof data === 'function' ? data() : data
162
203
  const entry = {
163
204
  seq: ++this._traceSeq,
164
205
  at: Date.now(),
165
206
  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
207
+ data: resolvedData
172
208
  }
173
209
  this._traceBuffer[this._traceBufferIndex] = entry
174
210
  this._traceBufferIndex = (this._traceBufferIndex + 1) % this.traceMaxEntries
@@ -181,6 +217,7 @@ class Aqua extends EventEmitter {
181
217
 
182
218
  getTrace(limit = 300) {
183
219
  const max = Math.max(1, Number(limit) || 300)
220
+ if (!this._traceBuffer) return []
184
221
  const count = Math.min(max, this._traceBufferCount)
185
222
  if (!count) return []
186
223
  const out = new Array(count)
@@ -195,7 +232,7 @@ class Aqua extends EventEmitter {
195
232
  }
196
233
 
197
234
  clearTrace() {
198
- this._traceBuffer.fill(undefined)
235
+ if (this._traceBuffer) this._traceBuffer.fill(undefined)
199
236
  this._traceBufferCount = 0
200
237
  this._traceBufferIndex = 0
201
238
  }
@@ -214,27 +251,124 @@ class Aqua extends EventEmitter {
214
251
  }
215
252
  }
216
253
 
254
+ queueVoiceStateUpdate(data) {
255
+ const guildId = data?.guild_id ? String(data.guild_id) : null
256
+ if (!guildId) return false
257
+
258
+ this._voiceStatePending.set(guildId, data)
259
+ if (!this._voiceStateQueued.has(guildId)) {
260
+ this._voiceStateQueued.add(guildId)
261
+ this._voiceStateQueue.push(guildId)
262
+ }
263
+
264
+ if (this.debugTrace) {
265
+ this._trace('voice.queue.enqueue', {
266
+ guildId,
267
+ size: this._voiceStateQueued.size
268
+ })
269
+ }
270
+ this._scheduleVoiceStateFlush()
271
+ return true
272
+ }
273
+
274
+ _scheduleVoiceStateFlush(delay = 0) {
275
+ if (this._voiceStateFlushTimer) return
276
+ this._voiceStateFlushTimer = setTimeout(
277
+ () => {
278
+ this._voiceStateFlushTimer = null
279
+ this._flushVoiceStateQueue()
280
+ },
281
+ Math.max(0, delay)
282
+ )
283
+ this._voiceStateFlushTimer.unref?.()
284
+ }
285
+
286
+ _flushVoiceStateQueue() {
287
+ if (!this._voiceStateQueued.size) return
288
+
289
+ const now = Date.now()
290
+ const waitFor =
291
+ VOICE_STATE_QUEUE_INTERVAL - (now - this._lastVoiceStateSendAt)
292
+ if (waitFor > 0) {
293
+ this._scheduleVoiceStateFlush(waitFor)
294
+ return
295
+ }
296
+
297
+ let guildId = null
298
+ while (this._voiceStateQueueHead < this._voiceStateQueue.length) {
299
+ const candidate = this._voiceStateQueue[this._voiceStateQueueHead]
300
+ this._voiceStateQueue[this._voiceStateQueueHead] = undefined
301
+ this._voiceStateQueueHead++
302
+ if (candidate && this._voiceStateQueued.has(candidate)) {
303
+ guildId = candidate
304
+ this._voiceStateQueued.delete(candidate)
305
+ break
306
+ }
307
+ }
308
+
309
+ if (
310
+ this._voiceStateQueueHead > 1024 ||
311
+ this._voiceStateQueueHead > this._voiceStateQueue.length / 2
312
+ ) {
313
+ this._voiceStateQueue = this._voiceStateQueue.slice(
314
+ this._voiceStateQueueHead
315
+ )
316
+ this._voiceStateQueueHead = 0
317
+ }
318
+
319
+ const data = guildId ? this._voiceStatePending.get(guildId) : null
320
+ if (guildId) this._voiceStatePending.delete(guildId)
321
+
322
+ if (data) {
323
+ this._lastVoiceStateSendAt = now
324
+ if (this.debugTrace) {
325
+ this._trace('voice.queue.send', {
326
+ guildId,
327
+ remaining: this._voiceStateQueued.size
328
+ })
329
+ }
330
+ _functions.safeCall(() => this.send({ op: 4, d: data }))
331
+ }
332
+
333
+ if (this._voiceStateQueued.size) {
334
+ this._scheduleVoiceStateFlush(VOICE_STATE_QUEUE_INTERVAL)
335
+ }
336
+ }
337
+
217
338
  _bindEventHandlers() {
218
339
  this._eventHandlers = {
219
340
  onNodeConnect: (node) => {
220
- this._trace('node.connect', { node: node?.name || node?.host })
341
+ if (this.debugTrace)
342
+ this._trace('node.connect', { node: node?.name || node?.host })
221
343
  this._invalidateCache()
222
344
  this._performCleanup()
223
345
  },
224
346
  onNodeDisconnect: (node) => {
225
- this._trace('node.disconnect', { node: node?.name || node?.host })
347
+ if (this.debugTrace)
348
+ this._trace('node.disconnect', { node: node?.name || node?.host })
226
349
  this._invalidateCache()
227
350
  queueMicrotask(() => {
228
- this._storeBrokenPlayers(node)
351
+ this._storeBrokenPlayers(node).catch((error) =>
352
+ reportSuppressedError(
353
+ this,
354
+ 'aqua.nodeDisconnect.storeBrokenPlayers',
355
+ error,
356
+ {
357
+ node: node?.name || node?.host
358
+ }
359
+ )
360
+ )
229
361
  this._performCleanup()
230
362
  })
231
363
  },
232
364
  onNodeReady: (node, { resumed }) => {
233
- this._trace('node.ready', {
234
- node: node?.name || node?.host,
235
- resumed: !!resumed,
236
- players: this.players.size
237
- })
365
+ if (this.debugTrace) {
366
+ this._trace('node.ready', {
367
+ node: node?.name || node?.host,
368
+ resumed: !!resumed,
369
+ players: this.players.size
370
+ })
371
+ }
238
372
  if (resumed) {
239
373
  const batch = []
240
374
  for (const player of this.players.values()) {
@@ -249,7 +383,16 @@ class Aqua extends EventEmitter {
249
383
  return
250
384
  }
251
385
  queueMicrotask(() => {
252
- this._rebuildBrokenPlayers(node).catch(_functions.noop)
386
+ this._rebuildBrokenPlayers(node).catch((error) =>
387
+ reportSuppressedError(
388
+ this,
389
+ 'aqua.nodeReady.rebuildBrokenPlayers',
390
+ error,
391
+ {
392
+ node: node?.name || node?.host
393
+ }
394
+ )
395
+ )
253
396
  })
254
397
  }
255
398
  }
@@ -269,19 +412,28 @@ class Aqua extends EventEmitter {
269
412
  this._eventHandlers = null
270
413
  }
271
414
  this.removeAllListeners()
415
+ if (this._voiceStateFlushTimer) {
416
+ clearTimeout(this._voiceStateFlushTimer)
417
+ this._voiceStateFlushTimer = null
418
+ }
419
+ this._voiceStateQueue.length = 0
420
+ this._voiceStateQueueHead = 0
421
+ this._voiceStateQueued.clear()
422
+ this._voiceStatePending.clear()
272
423
 
273
424
  for (const id of Array.from(this.nodeMap.keys())) this._destroyNode(id)
274
425
  for (const player of Array.from(this.players.values()))
275
426
  _functions.safeCall(() => player.destroy())
276
427
 
277
428
  this.players.clear()
278
- this._nodeStates.clear()
279
- this._failoverQueue.clear()
280
- this._lastFailoverAttempt.clear()
429
+ this._failoverState = Object.create(null)
430
+ this._guildLifecycleLocks.clear()
281
431
  this._brokenPlayers.clear()
282
432
  this._rebuildLocks.clear()
283
433
  this._nodeLoadCache.clear()
284
434
  this._invalidateCache()
435
+ _functions.safeCall(() => this._recovery?.dispose?.())
436
+ this._recovery = null
285
437
  }
286
438
 
287
439
  get leastUsedNodes() {
@@ -361,7 +513,9 @@ class Aqua extends EventEmitter {
361
513
 
362
514
  if (this.initiated) return this
363
515
  if (!this.clientId) return this
364
- await this._loadNodeSessions().catch(() => {})
516
+ await this._loadNodeSessions().catch((error) =>
517
+ reportSuppressedError(this, 'aqua.init.loadNodeSessions', error)
518
+ )
365
519
  const results = await Promise.allSettled(
366
520
  this.nodes.map((n) =>
367
521
  Promise.race([
@@ -389,10 +543,16 @@ class Aqua extends EventEmitter {
389
543
  const node = new Node(this, options, this.options)
390
544
  node.players = new Set()
391
545
  this.nodeMap.set(id, node)
392
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
546
+ this._failoverState[id] = {
547
+ connected: false,
548
+ failoverInProgress: false,
549
+ attempts: 0,
550
+ lastAttempt: 0
551
+ }
393
552
  try {
394
553
  await node.connect()
395
- this._nodeStates.set(id, { connected: true, failoverInProgress: false })
554
+ this._failoverState[id].connected = true
555
+ this._failoverState[id].failoverInProgress = false
396
556
  this._invalidateCache()
397
557
  this.emit(AqualinkEvents.NodeCreate, node)
398
558
  return node
@@ -416,361 +576,61 @@ class Aqua extends EventEmitter {
416
576
  _functions.safeCall(() => node.players.clear())
417
577
  this.nodeMap.delete(id)
418
578
  }
419
- this._nodeStates.delete(id)
420
- this._failoverQueue.delete(id)
421
- this._lastFailoverAttempt.delete(id)
579
+ delete this._failoverState[id]
422
580
  this._nodeLoadCache.delete(id)
423
581
  this._invalidateCache()
424
582
  }
425
583
 
426
584
  _storeBrokenPlayers(node) {
427
- const id = node.name || node.host
428
- const now = Date.now()
429
- for (const player of this.players.values()) {
430
- if (player.nodes !== node) continue
431
- const state = this._capturePlayerState(player)
432
- if (state) {
433
- state.originalNodeId = id
434
- state.brokenAt = now
435
- this._brokenPlayers.set(String(player.guildId), state)
436
- }
437
- }
585
+ return this._recovery.storeBrokenPlayers(node)
438
586
  }
439
587
 
440
588
  async _rebuildBrokenPlayers(node) {
441
- const id = node.name || node.host
442
- const rebuilds = []
443
- const now = Date.now()
444
- for (const [guildId, state] of this._brokenPlayers) {
445
- if (
446
- state.originalNodeId === id &&
447
- now - state.brokenAt < BROKEN_PLAYER_TTL
448
- ) {
449
- rebuilds.push({ guildId, state })
450
- }
451
- }
452
- if (!rebuilds.length) return
453
- const successes = []
454
- for (let i = 0; i < rebuilds.length; i += MAX_CONCURRENT_OPS) {
455
- const batch = rebuilds.slice(i, i + MAX_CONCURRENT_OPS)
456
- const results = await Promise.allSettled(
457
- batch.map(({ guildId, state }) =>
458
- this._rebuildPlayer(state, node).then(() => guildId)
459
- )
460
- )
461
- for (const r of results) {
462
- if (r.status === 'fulfilled') successes.push(r.value)
463
- }
464
- }
465
- for (const guildId of successes) this._brokenPlayers.delete(guildId)
466
- if (successes.length)
467
- this.emit(AqualinkEvents.PlayersRebuilt, node, successes.length)
468
- this._performCleanup()
589
+ return this._recovery.rebuildBrokenPlayers(node)
469
590
  }
470
591
 
471
592
  async _rebuildPlayer(state, targetNode) {
472
- const {
473
- guildId,
474
- textChannel,
475
- voiceChannel,
476
- current,
477
- volume = 65,
478
- deaf = true
479
- } = state
480
- const lockKey = `rebuild_${guildId}`
481
- if (this._rebuildLocks.has(lockKey)) return
482
- this._rebuildLocks.add(lockKey)
483
- try {
484
- if (this.players.has(guildId)) {
485
- await this.destroyPlayer(guildId)
486
- await _functions.delay(RECONNECT_DELAY)
487
- }
488
- const player = this.createPlayer(targetNode, {
489
- guildId,
490
- textChannel,
491
- voiceChannel,
492
- defaultVolume: volume,
493
- deaf
494
- })
495
- if (current && player?.queue?.add) {
496
- player.queue.add(current)
497
- await player.play()
498
- this._seekAfterTrackStart(player, guildId, state.position, 50)
499
- if (state.paused) player.pause(true)
500
- }
501
- return player
502
- } finally {
503
- this._rebuildLocks.delete(lockKey)
504
- }
593
+ return this._recovery.rebuildPlayer(state, targetNode)
505
594
  }
506
595
 
507
596
  async handleNodeFailover(failedNode) {
508
- if (!this.failoverOptions.enabled) return
509
- const id = failedNode.name || failedNode.host
510
- const now = Date.now()
511
- const state = this._nodeStates.get(id)
512
- if (state?.failoverInProgress) return
513
- const lastAttempt = this._lastFailoverAttempt.get(id)
514
- if (lastAttempt && now - lastAttempt < this.failoverOptions.cooldownTime)
515
- return
516
- const attempts = this._failoverQueue.get(id) || 0
517
- if (attempts >= this.failoverOptions.maxFailoverAttempts) return
518
-
519
- this._nodeStates.set(id, { connected: false, failoverInProgress: true })
520
- this._lastFailoverAttempt.set(id, now)
521
- this._failoverQueue.set(id, attempts + 1)
522
-
523
- try {
524
- this.emit(AqualinkEvents.NodeFailover, failedNode)
525
- const players = Array.from(failedNode.players || [])
526
- if (!players.length) return
527
- const available = []
528
- for (const n of this.nodeMap.values()) {
529
- if (n !== failedNode && n.connected) available.push(n)
530
- }
531
- if (!available.length) throw new Error('No failover nodes')
532
- const results = await this._migratePlayersOptimized(players, available)
533
- const successful = results.filter((r) => r.success).length
534
- if (successful) {
535
- this.emit(
536
- AqualinkEvents.NodeFailoverComplete,
537
- failedNode,
538
- successful,
539
- results.length - successful
540
- )
541
- this._performCleanup()
542
- }
543
- } catch (error) {
544
- this.emit(AqualinkEvents.Error, null, error)
545
- } finally {
546
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
547
- }
597
+ return this._recovery.handleNodeFailover(failedNode)
548
598
  }
549
599
 
550
600
  async _migratePlayersOptimized(players, nodes) {
551
- const loads = new Map()
552
- const counts = new Map()
553
- for (const n of nodes) {
554
- loads.set(n, this._getNodeLoad(n))
555
- counts.set(n, 0)
556
- }
557
- const pickNode = () => {
558
- let best = nodes[0],
559
- bestScore = loads.get(best) + counts.get(best)
560
- for (let i = 1; i < nodes.length; i++) {
561
- const score = loads.get(nodes[i]) + counts.get(nodes[i])
562
- if (score < bestScore) {
563
- best = nodes[i]
564
- bestScore = score
565
- }
566
- }
567
- counts.set(best, counts.get(best) + 1)
568
- return best
569
- }
570
- const results = []
571
- for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
572
- const batch = players.slice(i, i + MAX_CONCURRENT_OPS)
573
- const batchResults = await Promise.allSettled(
574
- batch.map((p) => this._migratePlayer(p, pickNode))
575
- )
576
- for (const r of batchResults)
577
- results.push({ success: r.status === 'fulfilled', error: r.reason })
578
- }
579
- return results
601
+ return this._recovery.migratePlayersOptimized(players, nodes)
580
602
  }
581
603
 
582
604
  async _migratePlayer(player, pickNode) {
583
- const state = this._capturePlayerState(player)
584
- if (!state) throw new Error('Failed to capture state')
585
- const { maxRetries, retryDelay } = this.failoverOptions
586
- for (let retry = 0; retry < maxRetries; retry++) {
587
- try {
588
- const targetNode = pickNode()
589
- const newPlayer = this._createPlayerOnNode(targetNode, state)
590
- await this._restorePlayerState(newPlayer, state)
591
- this.emit(AqualinkEvents.PlayerMigrated, player, newPlayer, targetNode)
592
- return newPlayer
593
- } catch (error) {
594
- if (retry === maxRetries - 1) throw error
595
- await _functions.delay(retryDelay * 1.5 ** retry)
596
- }
597
- }
605
+ return this._recovery.migratePlayer(player, pickNode)
598
606
  }
599
607
 
600
608
  _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
609
+ return this._recovery.regionMatches(configuredRegion, extractedRegion)
606
610
  }
607
611
 
608
612
  _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)
616
- }
617
- }
618
- if (!candidates.length) return null
619
- return this._chooseLeastBusyNode(candidates)
613
+ return this._recovery.findBestNodeForRegion(region)
620
614
  }
621
615
 
622
616
  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
617
+ return this._recovery.movePlayerToNode(guildId, targetNode, reason)
700
618
  }
701
619
 
702
620
  _capturePlayerState(player) {
703
- if (!player) return null
704
- let position = player.position || 0
705
- if (player.playing && !player.paused && player.timestamp) {
706
- const elapsed = Date.now() - player.timestamp
707
- position = Math.min(
708
- position + elapsed,
709
- player.current?.info?.length || position + elapsed
710
- )
711
- }
712
- return {
713
- guildId: player.guildId,
714
- textChannel: player.textChannel,
715
- voiceChannel: player.voiceChannel,
716
- volume: player.volume ?? 100,
717
- paused: !!player.paused,
718
- position,
719
- current: player.current || null,
720
- queue: player.queue?.toArray?.() || EMPTY_ARRAY,
721
- loop: player.loop,
722
- shuffle: player.shuffle,
723
- deaf: player.deaf ?? false,
724
- connected: !!player.connected
725
- }
621
+ return this._recovery.capturePlayerState(player)
726
622
  }
727
623
 
728
624
  _createPlayerOnNode(targetNode, state) {
729
- return this.createPlayer(targetNode, {
730
- guildId: state.guildId,
731
- textChannel: state.textChannel,
732
- voiceChannel: state.voiceChannel,
733
- defaultVolume: state.volume || 100,
734
- deaf: state.deaf || false
735
- })
625
+ return this._recovery.createPlayerOnNode(targetNode, state)
736
626
  }
737
627
 
738
628
  _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))
629
+ return this._recovery.seekAfterTrackStart(player, guildId, position, delay)
746
630
  }
747
631
 
748
632
  async _restorePlayerState(newPlayer, state) {
749
- const ops = []
750
- if (typeof state.volume === 'number') {
751
- if (typeof newPlayer.setVolume === 'function')
752
- ops.push(newPlayer.setVolume(state.volume))
753
- else newPlayer.volume = state.volume
754
- }
755
- if (state.queue?.length && newPlayer.queue?.add)
756
- newPlayer.queue.add(...state.queue)
757
- if (state.current && this.failoverOptions.preservePosition) {
758
- if (this.failoverOptions.resumePlayback) {
759
- ops.push(newPlayer.play(state.current))
760
- this._seekAfterTrackStart(
761
- newPlayer,
762
- newPlayer.guildId,
763
- state.position,
764
- 50
765
- )
766
- if (state.paused) ops.push(newPlayer.pause(true))
767
- } else if (newPlayer.queue?.add) {
768
- newPlayer.queue.add(state.current)
769
- }
770
- }
771
- newPlayer.loop = state.loop
772
- newPlayer.shuffle = state.shuffle
773
- await Promise.allSettled(ops)
633
+ return this._recovery.restorePlayerState(newPlayer, state)
774
634
  }
775
635
 
776
636
  updateVoiceState({ d, t }) {
@@ -781,13 +641,15 @@ class Aqua extends EventEmitter {
781
641
  return
782
642
  const player = this.players.get(String(d.guild_id))
783
643
  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
- })
644
+ if (this.debugTrace) {
645
+ this._trace('voice.gateway', {
646
+ guildId: String(d.guild_id),
647
+ type: t,
648
+ hasSessionId: !!d.session_id,
649
+ hasEndpoint: !!d.endpoint,
650
+ hasChannelId: d.channel_id !== undefined
651
+ })
652
+ }
791
653
 
792
654
  d.txId = player.txId
793
655
  if (t === 'VOICE_STATE_UPDATE') {
@@ -833,11 +695,12 @@ class Aqua extends EventEmitter {
833
695
  ? this.fetchRegion(options.region)
834
696
  : this.leastUsedNodes
835
697
  if (!candidates.length) throw new Error('No nodes available')
836
- return this.createPlayer(this._chooseLeastBusyNode(candidates), options)
698
+ return this.createPlayer(candidates[0], options)
837
699
  }
838
700
 
839
701
  createPlayer(node, options) {
840
- const existing = this.players.get(options.guildId)
702
+ const guildId = String(options.guildId)
703
+ const existing = this.players.get(guildId)
841
704
  if (existing) {
842
705
  _functions.safeCall(() =>
843
706
  existing.destroy({
@@ -848,15 +711,16 @@ class Aqua extends EventEmitter {
848
711
  )
849
712
  }
850
713
  const player = new Player(this, node, options)
851
- const guildId = String(options.guildId)
852
714
  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
- })
715
+ if (this.debugTrace) {
716
+ this._trace('player.create', {
717
+ guildId,
718
+ node: node?.name || node?.host,
719
+ voiceChannel: options.voiceChannel,
720
+ textChannel: options.textChannel,
721
+ resuming: !!options.resuming
722
+ })
723
+ }
860
724
  node?.players?.add?.(player)
861
725
  player.once('destroy', () => this._handlePlayerDestroy(player))
862
726
  player.connect(options)
@@ -868,10 +732,12 @@ class Aqua extends EventEmitter {
868
732
  player.nodes?.players?.delete?.(player)
869
733
  const guildId = String(player.guildId)
870
734
  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
- })
735
+ if (this.debugTrace) {
736
+ this._trace('player.destroyed', {
737
+ guildId,
738
+ node: player?.nodes?.name || player?.nodes?.host
739
+ })
740
+ }
875
741
  this.emit(AqualinkEvents.PlayerDestroyed, player)
876
742
  }
877
743
 
@@ -1079,194 +945,23 @@ class Aqua extends EventEmitter {
1079
945
  }
1080
946
 
1081
947
  async loadPlayers(filePath = './AquaPlayers.jsonl') {
1082
- if (this._loading) return
1083
- this._loading = true
1084
- const lockFile = `${filePath}.lock`
1085
- let stream = null,
1086
- rl = null
1087
- try {
1088
- await fs.promises.access(filePath)
1089
- await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
1090
- await this._waitForFirstNode()
1091
-
1092
- stream = fs.createReadStream(filePath, { encoding: 'utf8' })
1093
- rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
1094
-
1095
- const batch = []
1096
- for await (const line of rl) {
1097
- if (!line.trim()) continue
1098
- try {
1099
- const parsed = JSON.parse(line)
1100
- if (parsed.type === 'node_sessions') continue
1101
- batch.push(parsed)
1102
- } catch {
1103
- continue
1104
- }
1105
- if (batch.length >= PLAYER_BATCH_SIZE) {
1106
- await Promise.allSettled(batch.map((p) => this._restorePlayer(p)))
1107
- batch.length = 0
1108
- }
1109
- }
1110
- if (batch.length)
1111
- await Promise.allSettled(batch.map((p) => this._restorePlayer(p)))
1112
- await fs.promises.writeFile(filePath, '')
1113
- } catch (err) {
1114
- if (err.code !== 'ENOENT') {
1115
- console.error(`[Aqua/Autoresume]Error loading players:`, err)
1116
- this.emit(AqualinkEvents.Error, null, err)
1117
- }
1118
- } finally {
1119
- this._loading = false
1120
- if (rl) _functions.safeCall(() => rl.close())
1121
- if (stream) _functions.safeCall(() => stream.destroy())
1122
- await fs.promises.unlink(lockFile).catch(_functions.noop)
1123
- }
948
+ return this._recovery.loadPlayers(filePath)
1124
949
  }
1125
950
 
1126
951
  async _restorePlayer(p) {
1127
- try {
1128
- const gId = String(p.g)
1129
- const existing = this.players.get(gId)
1130
- if (existing?.playing) return
1131
-
1132
- const player =
1133
- existing ||
1134
- this.createPlayer(this._chooseLeastBusyNode(this.leastUsedNodes), {
1135
- guildId: gId,
1136
- textChannel: p.t,
1137
- voiceChannel: p.v,
1138
- defaultVolume: p.vol || 65,
1139
- deaf: true,
1140
- resuming: !!p.resuming
1141
- })
1142
- player._resuming = !!p.resuming
1143
- const requester = _functions.parseRequester(p.r)
1144
- const tracksToResolve = [p.u, ...(p.q || [])]
1145
- .filter(Boolean)
1146
- .slice(0, this.maxTracksRestore)
1147
- const resolved = await Promise.all(
1148
- tracksToResolve.map((uri) =>
1149
- this.resolve({ query: uri, requester }).catch(() => null)
1150
- )
1151
- )
1152
- const validTracks = resolved.flatMap((r) => r?.tracks || [])
1153
- if (validTracks.length && player.queue?.add) {
1154
- player.queue.add(...validTracks)
1155
- }
1156
- if (p.u && validTracks[0]) {
1157
- if (p.vol != null) {
1158
- if (typeof player.setVolume === 'function')
1159
- await player.setVolume(p.vol)
1160
- else player.volume = p.vol
1161
- }
1162
-
1163
- this._seekAfterTrackStart(player, gId, p.p, 100)
1164
-
1165
- await player.play(undefined, { startTime: p.p, paused: p.pa })
1166
- }
1167
- if (p.nw && p.t) {
1168
- const channel = this.client.channels?.cache?.get?.(p.t)
1169
- if (channel?.messages?.fetch) {
1170
- player.nowPlayingMessage = await channel.messages
1171
- .fetch(p.nw)
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
- })
1183
- }
1184
- } catch (e) {
1185
- console.error(
1186
- `[Aqua/Autoresume]Failed to restore player for guild: ${p.g}`,
1187
- e
1188
- )
1189
- }
952
+ return this._recovery.restorePlayer(p)
1190
953
  }
1191
954
 
1192
955
  async _waitForFirstNode(timeout = NODE_TIMEOUT) {
1193
- if (this.leastUsedNodes.length) return
1194
- return new Promise((resolve, reject) => {
1195
- let resolved = false
1196
- const cleanup = () => {
1197
- if (resolved) return
1198
- resolved = true
1199
- clearTimeout(timer)
1200
- this.off(AqualinkEvents.NodeConnect, onReady)
1201
- this.off(AqualinkEvents.NodeCreate, onReady)
1202
- }
1203
- const onReady = () => {
1204
- if (this.leastUsedNodes.length) {
1205
- cleanup()
1206
- resolve()
1207
- }
1208
- }
1209
- const timer = setTimeout(() => {
1210
- cleanup()
1211
- reject(new Error('Timeout waiting for first node'))
1212
- }, timeout)
1213
- timer.unref?.()
1214
- this.on(AqualinkEvents.NodeConnect, onReady)
1215
- this.on(AqualinkEvents.NodeCreate, onReady)
1216
- onReady()
1217
- })
956
+ return this._recovery.waitForFirstNode(timeout)
1218
957
  }
1219
958
 
1220
959
  _performCleanup() {
1221
- const now = Date.now()
1222
- for (const [guildId, state] of this._brokenPlayers) {
1223
- if (now - state.brokenAt > BROKEN_PLAYER_TTL)
1224
- this._brokenPlayers.delete(guildId)
1225
- }
1226
- for (const [id, ts] of this._lastFailoverAttempt) {
1227
- if (now - ts > FAILOVER_CLEANUP_TTL) {
1228
- this._lastFailoverAttempt.delete(id)
1229
- this._failoverQueue.delete(id)
1230
- }
1231
- }
1232
- if (this._failoverQueue.size > MAX_FAILOVER_QUEUE)
1233
- this._failoverQueue.clear()
1234
- if (this._rebuildLocks.size > MAX_REBUILD_LOCKS) this._rebuildLocks.clear()
1235
- for (const id of this._nodeStates.keys()) {
1236
- if (!this.nodeMap.has(id)) this._nodeStates.delete(id)
1237
- }
960
+ return this._recovery.performCleanup()
1238
961
  }
1239
962
 
1240
963
  async _loadNodeSessions(filePath = './AquaPlayers.jsonl') {
1241
- let stream = null,
1242
- rl = null
1243
- try {
1244
- await fs.promises.access(filePath)
1245
- stream = fs.createReadStream(filePath, { encoding: 'utf8' })
1246
- rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
1247
-
1248
- for await (const line of rl) {
1249
- if (!line.trim()) continue
1250
- try {
1251
- const parsed = JSON.parse(line)
1252
- if (parsed.type === 'node_sessions') {
1253
- for (const [name, sessionId] of Object.entries(parsed.data)) {
1254
- const nodeOptions = this.nodes.find(
1255
- (n) => (n.name || n.host) === name
1256
- )
1257
- if (nodeOptions) {
1258
- nodeOptions.sessionId = sessionId
1259
- }
1260
- }
1261
- break
1262
- }
1263
- } catch {}
1264
- }
1265
- } catch {
1266
- } finally {
1267
- if (rl) _functions.safeCall(() => rl.close())
1268
- if (stream) _functions.safeCall(() => stream.destroy())
1269
- }
964
+ return this._recovery.loadNodeSessions(filePath)
1270
965
  }
1271
966
  }
1272
967