aqualink 2.20.0 → 3.0.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
1
  const fs = require('node:fs')
2
- const readline = require('node:readline')
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,41 @@ 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 (!this._traceBuffer || this._traceBuffer.length !== this.traceMaxEntries) {
195
+ this._traceBuffer = new Array(this.traceMaxEntries)
196
+ this._traceBufferCount = 0
197
+ this._traceBufferIndex = 0
198
+ }
199
+ const resolvedData = typeof data === 'function' ? data() : data
162
200
  const entry = {
163
201
  seq: ++this._traceSeq,
164
202
  at: Date.now(),
165
203
  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
204
+ data: resolvedData
172
205
  }
173
206
  this._traceBuffer[this._traceBufferIndex] = entry
174
207
  this._traceBufferIndex = (this._traceBufferIndex + 1) % this.traceMaxEntries
@@ -181,6 +214,7 @@ class Aqua extends EventEmitter {
181
214
 
182
215
  getTrace(limit = 300) {
183
216
  const max = Math.max(1, Number(limit) || 300)
217
+ if (!this._traceBuffer) return []
184
218
  const count = Math.min(max, this._traceBufferCount)
185
219
  if (!count) return []
186
220
  const out = new Array(count)
@@ -195,7 +229,7 @@ class Aqua extends EventEmitter {
195
229
  }
196
230
 
197
231
  clearTrace() {
198
- this._traceBuffer.fill(undefined)
232
+ if (this._traceBuffer) this._traceBuffer.fill(undefined)
199
233
  this._traceBufferCount = 0
200
234
  this._traceBufferIndex = 0
201
235
  }
@@ -214,27 +248,119 @@ class Aqua extends EventEmitter {
214
248
  }
215
249
  }
216
250
 
251
+ queueVoiceStateUpdate(data) {
252
+ const guildId = data?.guild_id ? String(data.guild_id) : null
253
+ if (!guildId) return false
254
+
255
+ this._voiceStatePending.set(guildId, data)
256
+ if (!this._voiceStateQueued.has(guildId)) {
257
+ this._voiceStateQueued.add(guildId)
258
+ this._voiceStateQueue.push(guildId)
259
+ }
260
+
261
+ if (this.debugTrace) {
262
+ this._trace('voice.queue.enqueue', {
263
+ guildId,
264
+ size: this._voiceStateQueued.size
265
+ })
266
+ }
267
+ this._scheduleVoiceStateFlush()
268
+ return true
269
+ }
270
+
271
+ _scheduleVoiceStateFlush(delay = 0) {
272
+ if (this._voiceStateFlushTimer) return
273
+ this._voiceStateFlushTimer = setTimeout(
274
+ () => {
275
+ this._voiceStateFlushTimer = null
276
+ this._flushVoiceStateQueue()
277
+ },
278
+ Math.max(0, delay)
279
+ )
280
+ this._voiceStateFlushTimer.unref?.()
281
+ }
282
+
283
+ _flushVoiceStateQueue() {
284
+ if (!this._voiceStateQueued.size) return
285
+
286
+ const now = Date.now()
287
+ const waitFor =
288
+ VOICE_STATE_QUEUE_INTERVAL - (now - this._lastVoiceStateSendAt)
289
+ if (waitFor > 0) {
290
+ this._scheduleVoiceStateFlush(waitFor)
291
+ return
292
+ }
293
+
294
+ let guildId = null
295
+ while (this._voiceStateQueueHead < this._voiceStateQueue.length) {
296
+ const candidate = this._voiceStateQueue[this._voiceStateQueueHead]
297
+ this._voiceStateQueue[this._voiceStateQueueHead] = undefined
298
+ this._voiceStateQueueHead++
299
+ if (candidate && this._voiceStateQueued.has(candidate)) {
300
+ guildId = candidate
301
+ this._voiceStateQueued.delete(candidate)
302
+ break
303
+ }
304
+ }
305
+
306
+ if (
307
+ this._voiceStateQueueHead > 1024 ||
308
+ this._voiceStateQueueHead > this._voiceStateQueue.length / 2
309
+ ) {
310
+ this._voiceStateQueue = this._voiceStateQueue.slice(
311
+ this._voiceStateQueueHead
312
+ )
313
+ this._voiceStateQueueHead = 0
314
+ }
315
+
316
+ const data = guildId ? this._voiceStatePending.get(guildId) : null
317
+ if (guildId) this._voiceStatePending.delete(guildId)
318
+
319
+ if (data) {
320
+ this._lastVoiceStateSendAt = now
321
+ if (this.debugTrace) {
322
+ this._trace('voice.queue.send', {
323
+ guildId,
324
+ remaining: this._voiceStateQueued.size
325
+ })
326
+ }
327
+ _functions.safeCall(() => this.send({ op: 4, d: data }))
328
+ }
329
+
330
+ if (this._voiceStateQueued.size) {
331
+ this._scheduleVoiceStateFlush(VOICE_STATE_QUEUE_INTERVAL)
332
+ }
333
+ }
334
+
217
335
  _bindEventHandlers() {
218
336
  this._eventHandlers = {
219
337
  onNodeConnect: (node) => {
220
- this._trace('node.connect', { node: node?.name || node?.host })
338
+ if (this.debugTrace)
339
+ this._trace('node.connect', { node: node?.name || node?.host })
221
340
  this._invalidateCache()
222
341
  this._performCleanup()
223
342
  },
224
343
  onNodeDisconnect: (node) => {
225
- this._trace('node.disconnect', { node: node?.name || node?.host })
344
+ if (this.debugTrace)
345
+ this._trace('node.disconnect', { node: node?.name || node?.host })
226
346
  this._invalidateCache()
227
347
  queueMicrotask(() => {
228
- this._storeBrokenPlayers(node)
348
+ this._storeBrokenPlayers(node).catch((error) =>
349
+ reportSuppressedError(this, 'aqua.nodeDisconnect.storeBrokenPlayers', error, {
350
+ node: node?.name || node?.host
351
+ })
352
+ )
229
353
  this._performCleanup()
230
354
  })
231
355
  },
232
356
  onNodeReady: (node, { resumed }) => {
233
- this._trace('node.ready', {
234
- node: node?.name || node?.host,
235
- resumed: !!resumed,
236
- players: this.players.size
237
- })
357
+ if (this.debugTrace) {
358
+ this._trace('node.ready', {
359
+ node: node?.name || node?.host,
360
+ resumed: !!resumed,
361
+ players: this.players.size
362
+ })
363
+ }
238
364
  if (resumed) {
239
365
  const batch = []
240
366
  for (const player of this.players.values()) {
@@ -249,7 +375,16 @@ class Aqua extends EventEmitter {
249
375
  return
250
376
  }
251
377
  queueMicrotask(() => {
252
- this._rebuildBrokenPlayers(node).catch(_functions.noop)
378
+ this._rebuildBrokenPlayers(node).catch((error) =>
379
+ reportSuppressedError(
380
+ this,
381
+ 'aqua.nodeReady.rebuildBrokenPlayers',
382
+ error,
383
+ {
384
+ node: node?.name || node?.host
385
+ }
386
+ )
387
+ )
253
388
  })
254
389
  }
255
390
  }
@@ -269,19 +404,28 @@ class Aqua extends EventEmitter {
269
404
  this._eventHandlers = null
270
405
  }
271
406
  this.removeAllListeners()
407
+ if (this._voiceStateFlushTimer) {
408
+ clearTimeout(this._voiceStateFlushTimer)
409
+ this._voiceStateFlushTimer = null
410
+ }
411
+ this._voiceStateQueue.length = 0
412
+ this._voiceStateQueueHead = 0
413
+ this._voiceStateQueued.clear()
414
+ this._voiceStatePending.clear()
272
415
 
273
416
  for (const id of Array.from(this.nodeMap.keys())) this._destroyNode(id)
274
417
  for (const player of Array.from(this.players.values()))
275
418
  _functions.safeCall(() => player.destroy())
276
419
 
277
420
  this.players.clear()
278
- this._nodeStates.clear()
279
- this._failoverQueue.clear()
280
- this._lastFailoverAttempt.clear()
421
+ this._failoverState = Object.create(null)
422
+ this._guildLifecycleLocks.clear()
281
423
  this._brokenPlayers.clear()
282
424
  this._rebuildLocks.clear()
283
425
  this._nodeLoadCache.clear()
284
426
  this._invalidateCache()
427
+ _functions.safeCall(() => this._recovery?.dispose?.())
428
+ this._recovery = null
285
429
  }
286
430
 
287
431
  get leastUsedNodes() {
@@ -361,7 +505,9 @@ class Aqua extends EventEmitter {
361
505
 
362
506
  if (this.initiated) return this
363
507
  if (!this.clientId) return this
364
- await this._loadNodeSessions().catch(() => {})
508
+ await this._loadNodeSessions().catch((error) =>
509
+ reportSuppressedError(this, 'aqua.init.loadNodeSessions', error)
510
+ )
365
511
  const results = await Promise.allSettled(
366
512
  this.nodes.map((n) =>
367
513
  Promise.race([
@@ -389,10 +535,16 @@ class Aqua extends EventEmitter {
389
535
  const node = new Node(this, options, this.options)
390
536
  node.players = new Set()
391
537
  this.nodeMap.set(id, node)
392
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
538
+ this._failoverState[id] = {
539
+ connected: false,
540
+ failoverInProgress: false,
541
+ attempts: 0,
542
+ lastAttempt: 0
543
+ }
393
544
  try {
394
545
  await node.connect()
395
- this._nodeStates.set(id, { connected: true, failoverInProgress: false })
546
+ this._failoverState[id].connected = true
547
+ this._failoverState[id].failoverInProgress = false
396
548
  this._invalidateCache()
397
549
  this.emit(AqualinkEvents.NodeCreate, node)
398
550
  return node
@@ -416,361 +568,61 @@ class Aqua extends EventEmitter {
416
568
  _functions.safeCall(() => node.players.clear())
417
569
  this.nodeMap.delete(id)
418
570
  }
419
- this._nodeStates.delete(id)
420
- this._failoverQueue.delete(id)
421
- this._lastFailoverAttempt.delete(id)
571
+ delete this._failoverState[id]
422
572
  this._nodeLoadCache.delete(id)
423
573
  this._invalidateCache()
424
574
  }
425
575
 
426
576
  _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
- }
577
+ return this._recovery.storeBrokenPlayers(node)
438
578
  }
439
579
 
440
580
  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()
581
+ return this._recovery.rebuildBrokenPlayers(node)
469
582
  }
470
583
 
471
584
  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
- }
585
+ return this._recovery.rebuildPlayer(state, targetNode)
505
586
  }
506
587
 
507
588
  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
- }
589
+ return this._recovery.handleNodeFailover(failedNode)
548
590
  }
549
591
 
550
592
  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
593
+ return this._recovery.migratePlayersOptimized(players, nodes)
580
594
  }
581
595
 
582
596
  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
- }
597
+ return this._recovery.migratePlayer(player, pickNode)
598
598
  }
599
599
 
600
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
601
+ return this._recovery.regionMatches(configuredRegion, extractedRegion)
606
602
  }
607
603
 
608
604
  _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)
605
+ return this._recovery.findBestNodeForRegion(region)
620
606
  }
621
607
 
622
608
  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
609
+ return this._recovery.movePlayerToNode(guildId, targetNode, reason)
700
610
  }
701
611
 
702
612
  _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
- }
613
+ return this._recovery.capturePlayerState(player)
726
614
  }
727
615
 
728
616
  _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
- })
617
+ return this._recovery.createPlayerOnNode(targetNode, state)
736
618
  }
737
619
 
738
620
  _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))
621
+ return this._recovery.seekAfterTrackStart(player, guildId, position, delay)
746
622
  }
747
623
 
748
624
  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)
625
+ return this._recovery.restorePlayerState(newPlayer, state)
774
626
  }
775
627
 
776
628
  updateVoiceState({ d, t }) {
@@ -781,13 +633,15 @@ class Aqua extends EventEmitter {
781
633
  return
782
634
  const player = this.players.get(String(d.guild_id))
783
635
  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
- })
636
+ if (this.debugTrace) {
637
+ this._trace('voice.gateway', {
638
+ guildId: String(d.guild_id),
639
+ type: t,
640
+ hasSessionId: !!d.session_id,
641
+ hasEndpoint: !!d.endpoint,
642
+ hasChannelId: d.channel_id !== undefined
643
+ })
644
+ }
791
645
 
792
646
  d.txId = player.txId
793
647
  if (t === 'VOICE_STATE_UPDATE') {
@@ -833,11 +687,12 @@ class Aqua extends EventEmitter {
833
687
  ? this.fetchRegion(options.region)
834
688
  : this.leastUsedNodes
835
689
  if (!candidates.length) throw new Error('No nodes available')
836
- return this.createPlayer(this._chooseLeastBusyNode(candidates), options)
690
+ return this.createPlayer(candidates[0], options)
837
691
  }
838
692
 
839
693
  createPlayer(node, options) {
840
- const existing = this.players.get(options.guildId)
694
+ const guildId = String(options.guildId)
695
+ const existing = this.players.get(guildId)
841
696
  if (existing) {
842
697
  _functions.safeCall(() =>
843
698
  existing.destroy({
@@ -848,15 +703,16 @@ class Aqua extends EventEmitter {
848
703
  )
849
704
  }
850
705
  const player = new Player(this, node, options)
851
- const guildId = String(options.guildId)
852
706
  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
- })
707
+ if (this.debugTrace) {
708
+ this._trace('player.create', {
709
+ guildId,
710
+ node: node?.name || node?.host,
711
+ voiceChannel: options.voiceChannel,
712
+ textChannel: options.textChannel,
713
+ resuming: !!options.resuming
714
+ })
715
+ }
860
716
  node?.players?.add?.(player)
861
717
  player.once('destroy', () => this._handlePlayerDestroy(player))
862
718
  player.connect(options)
@@ -868,10 +724,12 @@ class Aqua extends EventEmitter {
868
724
  player.nodes?.players?.delete?.(player)
869
725
  const guildId = String(player.guildId)
870
726
  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
- })
727
+ if (this.debugTrace) {
728
+ this._trace('player.destroyed', {
729
+ guildId,
730
+ node: player?.nodes?.name || player?.nodes?.host
731
+ })
732
+ }
875
733
  this.emit(AqualinkEvents.PlayerDestroyed, player)
876
734
  }
877
735
 
@@ -1079,194 +937,23 @@ class Aqua extends EventEmitter {
1079
937
  }
1080
938
 
1081
939
  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
- }
940
+ return this._recovery.loadPlayers(filePath)
1124
941
  }
1125
942
 
1126
943
  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
- }
944
+ return this._recovery.restorePlayer(p)
1190
945
  }
1191
946
 
1192
947
  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
- })
948
+ return this._recovery.waitForFirstNode(timeout)
1218
949
  }
1219
950
 
1220
951
  _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
- }
952
+ return this._recovery.performCleanup()
1238
953
  }
1239
954
 
1240
955
  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
- }
956
+ return this._recovery.loadNodeSessions(filePath)
1270
957
  }
1271
958
  }
1272
959