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.
@@ -0,0 +1,901 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const readline = require('node:readline')
4
+ const { AqualinkEvents } = require('./AqualinkEvents')
5
+ const { reportSuppressedError } = require('./Reporting')
6
+
7
+ class AquaRecovery {
8
+ constructor(aqua, deps) {
9
+ this.aqua = aqua
10
+ this._functions = deps._functions
11
+ this.MAX_CONCURRENT_OPS = deps.MAX_CONCURRENT_OPS
12
+ this.BROKEN_PLAYER_TTL = deps.BROKEN_PLAYER_TTL
13
+ this.FAILOVER_CLEANUP_TTL = deps.FAILOVER_CLEANUP_TTL
14
+ this.MAX_FAILOVER_QUEUE = deps.MAX_FAILOVER_QUEUE
15
+ this.MAX_REBUILD_LOCKS = deps.MAX_REBUILD_LOCKS
16
+ this.PLAYER_BATCH_SIZE = deps.PLAYER_BATCH_SIZE
17
+ this.RECONNECT_DELAY = deps.RECONNECT_DELAY
18
+ this.NODE_TIMEOUT = deps.NODE_TIMEOUT
19
+ this.EMPTY_ARRAY = deps.EMPTY_ARRAY
20
+ this._trackResolveActive = 0
21
+ this._trackResolveQueue = []
22
+ this._brokenSnapshotNodes = new Set()
23
+ this._brokenSnapshotWrites = new Map()
24
+ }
25
+
26
+ _stateFor(id) {
27
+ const existing = this.aqua._failoverState[id]
28
+ if (existing) return existing
29
+
30
+ const created = {
31
+ connected: false,
32
+ failoverInProgress: false,
33
+ attempts: 0,
34
+ lastAttempt: 0
35
+ }
36
+ this.aqua._failoverState[id] = created
37
+ return created
38
+ }
39
+
40
+ _deleteState(id) {
41
+ delete this.aqua._failoverState[id]
42
+ }
43
+
44
+ withGuildLifecycleLock(guildId, scope, fn) {
45
+ const id = String(guildId)
46
+ const previous = this.aqua._guildLifecycleLocks.get(id) || Promise.resolve()
47
+ const run = previous
48
+ .catch(() => {})
49
+ .then(async () => {
50
+ if (this.aqua?.debugTrace) {
51
+ this.aqua._trace('guild.lock.acquire', { guildId: id, scope })
52
+ }
53
+ try {
54
+ return await fn()
55
+ } finally {
56
+ if (this.aqua?.debugTrace) {
57
+ this.aqua._trace('guild.lock.release', { guildId: id, scope })
58
+ }
59
+ }
60
+ })
61
+
62
+ const tail = run.finally(() => {
63
+ if (this.aqua._guildLifecycleLocks.get(id) === tail) {
64
+ this.aqua._guildLifecycleLocks.delete(id)
65
+ }
66
+ })
67
+
68
+ this.aqua._guildLifecycleLocks.set(id, tail)
69
+ return run
70
+ }
71
+
72
+ storeBrokenPlayers(node) {
73
+ const id = node.name || node.host
74
+ const now = Date.now()
75
+ const records = []
76
+ for (const player of this.aqua.players.values()) {
77
+ if (player.nodes !== node) continue
78
+ const record = this._serializeBrokenPlayer(player, id, now)
79
+ if (!record) continue
80
+ this.aqua._brokenPlayers.set(String(player.guildId), {
81
+ originalNodeId: id,
82
+ brokenAt: now
83
+ })
84
+ records.push(record)
85
+ }
86
+ return this._writeBrokenPlayerSnapshot(id, records)
87
+ }
88
+
89
+ async rebuildBrokenPlayers(node) {
90
+ const id = node.name || node.host
91
+ const rebuildGuilds = new Set()
92
+ const now = Date.now()
93
+ for (const [guildId, state] of this.aqua._brokenPlayers) {
94
+ if (
95
+ state.originalNodeId === id &&
96
+ now - state.brokenAt < this.BROKEN_PLAYER_TTL
97
+ ) {
98
+ rebuildGuilds.add(guildId)
99
+ }
100
+ }
101
+ if (!rebuildGuilds.size) {
102
+ await this._deleteBrokenPlayerSnapshot(id)
103
+ return
104
+ }
105
+ const pendingWrite = this._brokenSnapshotWrites.get(id)
106
+ if (pendingWrite) await pendingWrite.catch(this._functions.noop)
107
+ const rebuilds = await this._readBrokenPlayerSnapshot(id, rebuildGuilds)
108
+ if (!rebuilds.length) return
109
+ const successes = []
110
+ const failed = []
111
+ for (let i = 0; i < rebuilds.length; i += this.MAX_CONCURRENT_OPS) {
112
+ const batch = rebuilds.slice(i, i + this.MAX_CONCURRENT_OPS)
113
+ const results = await Promise.allSettled(
114
+ batch.map((state) =>
115
+ this.restorePlayer(state, node).then((ok) => ({ ok, guildId: state.g }))
116
+ )
117
+ )
118
+ for (let j = 0; j < results.length; j++) {
119
+ const result = results[j]
120
+ const state = batch[j]
121
+ if (result.status === 'fulfilled' && result.value?.ok) {
122
+ successes.push(result.value.guildId)
123
+ } else {
124
+ failed.push(state)
125
+ }
126
+ }
127
+ }
128
+ for (const guildId of successes) this.aqua._brokenPlayers.delete(guildId)
129
+ for (const state of failed) {
130
+ this.aqua._brokenPlayers.set(String(state.g), {
131
+ originalNodeId: id,
132
+ brokenAt: state.brokenAt || now
133
+ })
134
+ }
135
+ await this._writeBrokenPlayerSnapshot(id, failed)
136
+ if (successes.length)
137
+ this.aqua.emit(AqualinkEvents.PlayersRebuilt, node, successes.length)
138
+ this.performCleanup()
139
+ }
140
+
141
+ async rebuildPlayer(state, targetNode) {
142
+ const {
143
+ guildId,
144
+ textChannel,
145
+ voiceChannel,
146
+ current,
147
+ volume = 65,
148
+ deaf = true
149
+ } = state
150
+ const id = String(guildId)
151
+ return this.withGuildLifecycleLock(id, 'rebuild', async () => {
152
+ const lockKey = `rebuild_${id}`
153
+ if (this.aqua._rebuildLocks.has(lockKey)) return
154
+ this.aqua._rebuildLocks.add(lockKey)
155
+ try {
156
+ if (this.aqua.players.has(id)) {
157
+ await this.aqua.destroyPlayer(id)
158
+ await this._functions.delay(this.RECONNECT_DELAY)
159
+ }
160
+ const player = this.aqua.createPlayer(targetNode, {
161
+ guildId: id,
162
+ textChannel,
163
+ voiceChannel,
164
+ defaultVolume: volume,
165
+ deaf,
166
+ mute: !!state.mute,
167
+ resuming: true
168
+ })
169
+ if (current && player?.queue?.add) {
170
+ player.queue.add(current)
171
+ await player.play()
172
+ this.seekAfterTrackStart(player, id, state.position, 50)
173
+ if (state.paused) player.pause(true)
174
+ }
175
+ return player
176
+ } finally {
177
+ this.aqua._rebuildLocks.delete(lockKey)
178
+ }
179
+ })
180
+ }
181
+
182
+ async handleNodeFailover(failedNode) {
183
+ if (!this.aqua.failoverOptions.enabled) return
184
+ const id = failedNode.name || failedNode.host
185
+ const now = Date.now()
186
+ const state = this._stateFor(id)
187
+ if (state.failoverInProgress) return
188
+ if (
189
+ state.lastAttempt &&
190
+ now - state.lastAttempt < this.aqua.failoverOptions.cooldownTime
191
+ )
192
+ return
193
+ if (state.attempts >= this.aqua.failoverOptions.maxFailoverAttempts) return
194
+
195
+ state.connected = false
196
+ state.failoverInProgress = true
197
+ state.lastAttempt = now
198
+ state.attempts++
199
+
200
+ try {
201
+ this.aqua.emit(AqualinkEvents.NodeFailover, failedNode)
202
+ const players = Array.from(failedNode.players || [])
203
+ if (!players.length) return
204
+ const available = []
205
+ for (const node of this.aqua.nodeMap.values()) {
206
+ if (node !== failedNode && node.connected) available.push(node)
207
+ }
208
+ if (!available.length) throw new Error('No failover nodes')
209
+ const results = await this.migratePlayersOptimized(players, available)
210
+ const successful = results.filter((r) => r.success).length
211
+ if (successful) {
212
+ this.aqua.emit(
213
+ AqualinkEvents.NodeFailoverComplete,
214
+ failedNode,
215
+ successful,
216
+ results.length - successful
217
+ )
218
+ this.performCleanup()
219
+ }
220
+ } catch (error) {
221
+ this.aqua.emit(AqualinkEvents.Error, null, error)
222
+ } finally {
223
+ state.failoverInProgress = false
224
+ }
225
+ }
226
+
227
+ async migratePlayersOptimized(players, nodes) {
228
+ const loads = nodes.map((node) => this.aqua._getNodeLoad(node))
229
+ const counts = new Array(nodes.length).fill(0)
230
+ const pickNode = () => {
231
+ let bestIndex = 0
232
+ let bestScore = loads[0] + counts[0]
233
+ for (let i = 1; i < nodes.length; i++) {
234
+ const score = loads[i] + counts[i]
235
+ if (score < bestScore) {
236
+ bestIndex = i
237
+ bestScore = score
238
+ }
239
+ }
240
+ counts[bestIndex]++
241
+ return nodes[bestIndex]
242
+ }
243
+ const results = []
244
+ for (let i = 0; i < players.length; i += this.MAX_CONCURRENT_OPS) {
245
+ const batch = players.slice(i, i + this.MAX_CONCURRENT_OPS)
246
+ const batchResults = await Promise.allSettled(
247
+ batch.map((player) => this.migratePlayer(player, pickNode))
248
+ )
249
+ for (const result of batchResults) {
250
+ results.push({
251
+ success: result.status === 'fulfilled',
252
+ error: result.reason
253
+ })
254
+ }
255
+ }
256
+ return results
257
+ }
258
+
259
+ async migratePlayer(player, pickNode) {
260
+ const guildId = String(player?.guildId)
261
+ return this.withGuildLifecycleLock(
262
+ guildId,
263
+ 'failover-migrate',
264
+ async () => {
265
+ const state = this.capturePlayerState(player)
266
+ if (!state) throw new Error('Failed to capture state')
267
+ const { maxRetries, retryDelay } = this.aqua.failoverOptions
268
+ for (let retry = 0; retry < maxRetries; retry++) {
269
+ try {
270
+ const targetNode = pickNode()
271
+ const newPlayer = this.createPlayerOnNode(targetNode, state)
272
+ await this.restorePlayerState(newPlayer, state)
273
+ this.aqua.emit(
274
+ AqualinkEvents.PlayerMigrated,
275
+ player,
276
+ newPlayer,
277
+ targetNode
278
+ )
279
+ return newPlayer
280
+ } catch (error) {
281
+ if (retry === maxRetries - 1) throw error
282
+ await this._functions.delay(retryDelay * 1.5 ** retry)
283
+ }
284
+ }
285
+ }
286
+ )
287
+ }
288
+
289
+ regionMatches(configuredRegion, extractedRegion) {
290
+ if (!configuredRegion || !extractedRegion) return false
291
+ const configured = String(configuredRegion).trim().toLowerCase()
292
+ const extracted = String(extractedRegion).trim().toLowerCase()
293
+ if (!configured || !extracted) return false
294
+ return configured === extracted
295
+ }
296
+
297
+ findBestNodeForRegion(region) {
298
+ if (!region) return null
299
+ const candidates = []
300
+ for (const node of this.aqua.nodeMap.values()) {
301
+ if (!node?.connected) continue
302
+ const regions = Array.isArray(node.regions) ? node.regions : []
303
+ if (regions.some((r) => this.regionMatches(r, region))) {
304
+ candidates.push(node)
305
+ }
306
+ }
307
+ if (!candidates.length) return null
308
+ return this.aqua._chooseLeastBusyNode(candidates)
309
+ }
310
+
311
+ async movePlayerToNode(guildId, targetNode, reason = 'region') {
312
+ const id = String(guildId)
313
+ return this.withGuildLifecycleLock(id, `move:${reason}`, async () => {
314
+ const player = this.aqua.players.get(id)
315
+ if (!player || player.destroyed)
316
+ throw new Error(`Player not found: ${id}`)
317
+ if (!targetNode?.connected)
318
+ throw new Error('Target node is not connected')
319
+ if (player.nodes === targetNode || player.nodes?.name === targetNode.name)
320
+ return player
321
+
322
+ const state = this.capturePlayerState(player)
323
+ if (!state) throw new Error(`Failed to capture state for ${id}`)
324
+ const oldPlayer = player
325
+ const oldNode = oldPlayer.nodes
326
+ const oldMessage = oldPlayer.nowPlayingMessage || null
327
+ const oldConn = oldPlayer.connection
328
+ const oldVoice = oldConn
329
+ ? {
330
+ sessionId: oldConn.sessionId || null,
331
+ endpoint: oldConn.endpoint || null,
332
+ token: oldConn.token || null,
333
+ region: oldConn.region || null,
334
+ channelId: oldConn.channelId || null
335
+ }
336
+ : null
337
+
338
+ oldPlayer.destroy({
339
+ preserveClient: true,
340
+ skipRemote: true,
341
+ preserveMessage: true,
342
+ preserveTracks: true,
343
+ preserveReconnecting: true
344
+ })
345
+
346
+ const newPlayer = this.aqua.createPlayer(targetNode, {
347
+ guildId: state.guildId,
348
+ textChannel: state.textChannel,
349
+ voiceChannel: state.voiceChannel,
350
+ defaultVolume: state.volume || 100,
351
+ deaf: state.deaf || false,
352
+ mute: state.mute || false,
353
+ resuming: true,
354
+ preserveMessage: true
355
+ })
356
+
357
+ if (
358
+ this._applyVoiceBootstrap(newPlayer, {
359
+ sid: oldVoice?.sessionId,
360
+ ep: oldVoice?.endpoint,
361
+ tok: oldVoice?.token,
362
+ reg: oldVoice?.region,
363
+ cid: oldVoice?.channelId
364
+ })
365
+ ) {
366
+ if (this.aqua.debugTrace) {
367
+ this.aqua._trace('player.migrate.voiceBootstrap', {
368
+ guildId: id,
369
+ from: oldNode?.name || oldNode?.host,
370
+ to: targetNode?.name || targetNode?.host,
371
+ hasSessionId: !!newPlayer.connection.sessionId,
372
+ hasEndpoint: !!newPlayer.connection.endpoint,
373
+ hasToken: !!newPlayer.connection.token
374
+ })
375
+ }
376
+ }
377
+
378
+ await this.restorePlayerState(newPlayer, state)
379
+ if (oldMessage) newPlayer.nowPlayingMessage = oldMessage
380
+
381
+ if (this.aqua.debugTrace) {
382
+ this.aqua._trace('player.migrated', {
383
+ guildId: id,
384
+ reason,
385
+ from: oldNode?.name || oldNode?.host,
386
+ to: targetNode?.name || targetNode?.host,
387
+ region:
388
+ newPlayer?.connection?.region ||
389
+ oldPlayer?.connection?.region ||
390
+ null
391
+ })
392
+ }
393
+ this.aqua.emit(
394
+ AqualinkEvents.PlayerMigrated,
395
+ oldPlayer,
396
+ newPlayer,
397
+ targetNode
398
+ )
399
+ return newPlayer
400
+ })
401
+ }
402
+
403
+ capturePlayerState(player) {
404
+ if (!player) return null
405
+ let position = player.position || 0
406
+ if (player.playing && !player.paused && player.timestamp) {
407
+ const elapsed = Date.now() - player.timestamp
408
+ position = Math.min(
409
+ position + elapsed,
410
+ player.current?.info?.length || position + elapsed
411
+ )
412
+ }
413
+ return {
414
+ guildId: player.guildId,
415
+ textChannel: player.textChannel,
416
+ voiceChannel: player.voiceChannel,
417
+ volume: player.volume ?? 100,
418
+ paused: !!player.paused,
419
+ position,
420
+ current: player.current || null,
421
+ queue: player.queue?.toArray?.() || this.EMPTY_ARRAY,
422
+ loop: player.loop,
423
+ shuffle: player.shuffle,
424
+ deaf: player.deaf ?? false,
425
+ mute: !!player.mute,
426
+ connected: !!player.connected
427
+ }
428
+ }
429
+
430
+ createPlayerOnNode(targetNode, state) {
431
+ return this.aqua.createPlayer(targetNode, {
432
+ guildId: state.guildId,
433
+ textChannel: state.textChannel,
434
+ voiceChannel: state.voiceChannel,
435
+ defaultVolume: state.volume || 100,
436
+ deaf: state.deaf || false,
437
+ mute: !!state.mute,
438
+ resuming: true
439
+ })
440
+ }
441
+
442
+ seekAfterTrackStart(player, guildId, position, delay = 50) {
443
+ if (!player || !guildId || !(position > 0)) return
444
+ const seekOnce = (startedPlayer) => {
445
+ if (startedPlayer.guildId !== guildId) return
446
+ this._functions.unrefTimeout(() => player.seek?.(position), delay)
447
+ }
448
+ this.aqua.once(AqualinkEvents.TrackStart, seekOnce)
449
+ player.once('destroy', () =>
450
+ this.aqua.off(AqualinkEvents.TrackStart, seekOnce)
451
+ )
452
+ }
453
+
454
+ async restorePlayerState(newPlayer, state) {
455
+ const ops = []
456
+ if (typeof state.volume === 'number') {
457
+ if (typeof newPlayer.setVolume === 'function')
458
+ ops.push(newPlayer.setVolume(state.volume))
459
+ else newPlayer.volume = state.volume
460
+ }
461
+ if (state.queue?.length && newPlayer.queue?.add)
462
+ newPlayer.queue.add(...state.queue)
463
+ if (state.current && this.aqua.failoverOptions.preservePosition) {
464
+ if (this.aqua.failoverOptions.resumePlayback) {
465
+ ops.push(newPlayer.play(state.current))
466
+ this.seekAfterTrackStart(
467
+ newPlayer,
468
+ newPlayer.guildId,
469
+ state.position,
470
+ 50
471
+ )
472
+ if (state.paused) ops.push(newPlayer.pause(true))
473
+ } else if (newPlayer.queue?.add) {
474
+ newPlayer.queue.add(state.current)
475
+ }
476
+ }
477
+ newPlayer.loop = state.loop
478
+ newPlayer.shuffle = state.shuffle
479
+ await Promise.allSettled(ops)
480
+ }
481
+
482
+ async loadPlayers(filePath = './AquaPlayers.jsonl') {
483
+ if (this.aqua._loading) return
484
+ this.aqua._loading = true
485
+ const lockFile = `${filePath}.lock`
486
+ let stream = null,
487
+ rl = null
488
+ try {
489
+ await fs.promises.access(filePath)
490
+ await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
491
+ await this.waitForFirstNode()
492
+
493
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' })
494
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
495
+
496
+ const batch = []
497
+ const failed = []
498
+ let nodeSessions = null
499
+ const flushBatch = async () => {
500
+ if (!batch.length) return
501
+ const entries = batch.splice(0, batch.length)
502
+ const results = await Promise.allSettled(
503
+ entries.map((p) => this.restorePlayer(p))
504
+ )
505
+ for (let i = 0; i < results.length; i++) {
506
+ if (results[i].status !== 'fulfilled' || !results[i].value)
507
+ failed.push(entries[i])
508
+ }
509
+ }
510
+ for await (const line of rl) {
511
+ if (!line.trim()) continue
512
+ try {
513
+ const parsed = JSON.parse(line)
514
+ if (parsed.type === 'node_sessions') {
515
+ nodeSessions = parsed
516
+ continue
517
+ }
518
+ batch.push(parsed)
519
+ } catch {
520
+ continue
521
+ }
522
+ if (batch.length >= this.PLAYER_BATCH_SIZE) await flushBatch()
523
+ }
524
+ await flushBatch()
525
+
526
+ const lines = []
527
+ if (nodeSessions) lines.push(JSON.stringify(nodeSessions))
528
+ for (const entry of failed) lines.push(JSON.stringify(entry))
529
+ await fs.promises.writeFile(
530
+ filePath,
531
+ lines.length ? `${lines.join('\n')}\n` : ''
532
+ )
533
+ } catch (error) {
534
+ if (error.code !== 'ENOENT') {
535
+ console.error(`[Aqua/Autoresume]Error loading players:`, error)
536
+ this.aqua.emit(AqualinkEvents.Error, null, error)
537
+ }
538
+ } finally {
539
+ this.aqua._loading = false
540
+ if (rl) this._functions.safeCall(() => rl.close())
541
+ if (stream) this._functions.safeCall(() => stream.destroy())
542
+ await fs.promises.unlink(lockFile).catch(this._functions.noop)
543
+ }
544
+ }
545
+
546
+ async restorePlayer(p, preferredNode = null) {
547
+ const gId = String(p.g)
548
+ return this.withGuildLifecycleLock(gId, 'restore', async () => {
549
+ try {
550
+ const existing = this.aqua.players.get(gId)
551
+ if (existing?.playing && !existing.destroyed) return true
552
+ if (existing?.destroyed) this.aqua.players.delete(gId)
553
+
554
+ const targetNode =
555
+ preferredNode?.connected ? preferredNode : this.aqua.leastUsedNodes[0]
556
+ if (!targetNode?.connected) {
557
+ throw new Error(`No connected node available to restore guild ${gId}`)
558
+ }
559
+
560
+ const player =
561
+ !existing || existing.destroyed
562
+ ? this.aqua.createPlayer(targetNode, {
563
+ guildId: gId,
564
+ textChannel: p.t,
565
+ voiceChannel: p.v,
566
+ defaultVolume: p.vol || 65,
567
+ deaf: p.d ?? true,
568
+ mute: !!p.m,
569
+ resuming: !!p.resuming
570
+ })
571
+ : existing
572
+ player._resuming = !!p.resuming
573
+ this._applyVoiceBootstrap(player, p.vs)
574
+ const requester = this._functions.parseRequester(p.r)
575
+ const tracksToResolve = [p.u, ...(p.q || [])]
576
+ .filter(Boolean)
577
+ .slice(0, this.aqua.maxTracksRestore)
578
+ const resolved = await Promise.all(
579
+ tracksToResolve.map((uri) =>
580
+ this._resolveTrackWithLimit(() =>
581
+ this.aqua.resolve({ query: uri, requester }).catch(() => null)
582
+ )
583
+ )
584
+ )
585
+ const validTracks = resolved.flatMap((result) => result?.tracks || [])
586
+ if (validTracks.length && player.queue?.add) {
587
+ player.queue.add(...validTracks)
588
+ }
589
+ if (typeof p.loop === 'number') player.loop = p.loop
590
+ if (p.sh !== undefined) player.shuffle = p.sh
591
+ if (p.u && validTracks[0]) {
592
+ if (p.vol != null) {
593
+ if (typeof player.setVolume === 'function')
594
+ await player.setVolume(p.vol)
595
+ else player.volume = p.vol
596
+ }
597
+
598
+ this.seekAfterTrackStart(player, gId, p.p, 100)
599
+ await player.play(undefined, { startTime: p.p, paused: p.pa })
600
+ }
601
+ if (p.nw && p.t) {
602
+ const channel = this.aqua.client.channels?.cache?.get?.(p.t)
603
+ if (channel?.messages?.fetch) {
604
+ player.nowPlayingMessage = await channel.messages
605
+ .fetch(p.nw)
606
+ .catch(() => null)
607
+ } else if (this.aqua.client.messages?.fetch) {
608
+ player.nowPlayingMessage = await this.aqua.client.messages
609
+ .fetch(p.nw, p.t)
610
+ .catch(() => null)
611
+ }
612
+ if (this.aqua.debugTrace) {
613
+ this.aqua._trace('player.nowPlaying.restore', {
614
+ guildId: gId,
615
+ messageId: p.nw,
616
+ restored: !!player.nowPlayingMessage
617
+ })
618
+ }
619
+ }
620
+ return true
621
+ } catch (error) {
622
+ console.error(
623
+ `[Aqua/Autoresume]Failed to restore player for guild: ${p.g}`,
624
+ error
625
+ )
626
+ return false
627
+ }
628
+ })
629
+ }
630
+
631
+ async waitForFirstNode(timeout = this.NODE_TIMEOUT) {
632
+ if (this.aqua.leastUsedNodes.length) return
633
+ return new Promise((resolve, reject) => {
634
+ let settled = false
635
+ const cleanup = () => {
636
+ if (settled) return
637
+ settled = true
638
+ clearTimeout(timer)
639
+ this.aqua.off(AqualinkEvents.NodeConnect, onReady)
640
+ this.aqua.off(AqualinkEvents.NodeCreate, onReady)
641
+ }
642
+ const onReady = () => {
643
+ if (this.aqua.leastUsedNodes.length) {
644
+ cleanup()
645
+ resolve()
646
+ }
647
+ }
648
+ const timer = setTimeout(() => {
649
+ cleanup()
650
+ reject(new Error('Timeout waiting for first node'))
651
+ }, timeout)
652
+ timer.unref?.()
653
+ this.aqua.on(AqualinkEvents.NodeConnect, onReady)
654
+ this.aqua.on(AqualinkEvents.NodeCreate, onReady)
655
+ onReady()
656
+ })
657
+ }
658
+
659
+ performCleanup() {
660
+ const now = Date.now()
661
+ for (const [guildId, state] of this.aqua._brokenPlayers) {
662
+ if (now - state.brokenAt > this.BROKEN_PLAYER_TTL) {
663
+ this.aqua._brokenPlayers.delete(guildId)
664
+ }
665
+ }
666
+
667
+ const activeBrokenNodes = new Set()
668
+ for (const state of this.aqua._brokenPlayers.values()) {
669
+ if (state?.originalNodeId) activeBrokenNodes.add(state.originalNodeId)
670
+ }
671
+ for (const nodeId of this._brokenSnapshotNodes) {
672
+ if (!activeBrokenNodes.has(nodeId)) {
673
+ this._deleteBrokenPlayerSnapshot(nodeId).catch(this._functions.noop)
674
+ }
675
+ }
676
+
677
+ const ids = Object.keys(this.aqua._failoverState)
678
+ if (ids.length > this.MAX_FAILOVER_QUEUE) {
679
+ this.aqua._failoverState = Object.create(null)
680
+ } else {
681
+ for (const id of ids) {
682
+ const state = this.aqua._failoverState[id]
683
+ if (!this.aqua.nodeMap.has(id)) {
684
+ this._deleteState(id)
685
+ continue
686
+ }
687
+ if (
688
+ state.lastAttempt &&
689
+ now - state.lastAttempt > this.FAILOVER_CLEANUP_TTL
690
+ ) {
691
+ state.lastAttempt = 0
692
+ state.attempts = 0
693
+ }
694
+ }
695
+ }
696
+
697
+ if (this.aqua._rebuildLocks.size > this.MAX_REBUILD_LOCKS) {
698
+ this.aqua._rebuildLocks.clear()
699
+ }
700
+ }
701
+
702
+ async loadNodeSessions(filePath = './AquaPlayers.jsonl') {
703
+ let stream = null,
704
+ rl = null
705
+ try {
706
+ await fs.promises.access(filePath)
707
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' })
708
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
709
+
710
+ for await (const line of rl) {
711
+ if (!line.trim()) continue
712
+ try {
713
+ const parsed = JSON.parse(line)
714
+ if (parsed.type === 'node_sessions') {
715
+ for (const [name, sessionId] of Object.entries(parsed.data)) {
716
+ const nodeOptions = this.aqua.nodes.find(
717
+ (n) => (n.name || n.host) === name
718
+ )
719
+ if (nodeOptions) nodeOptions.sessionId = sessionId
720
+ }
721
+ break
722
+ }
723
+ } catch {}
724
+ }
725
+ } catch (error) {
726
+ if (error?.code !== 'ENOENT') {
727
+ reportSuppressedError(this.aqua, 'aqua.loadNodeSessions', error)
728
+ }
729
+ } finally {
730
+ if (rl) this._functions.safeCall(() => rl.close())
731
+ if (stream) this._functions.safeCall(() => stream.destroy())
732
+ }
733
+ }
734
+
735
+ async dispose() {
736
+ const deletions = []
737
+ for (const pending of this._brokenSnapshotWrites.values()) {
738
+ deletions.push(pending.catch(this._functions.noop))
739
+ }
740
+ for (const nodeId of this._brokenSnapshotNodes) {
741
+ deletions.push(this._deleteBrokenPlayerSnapshot(nodeId))
742
+ }
743
+ this._brokenSnapshotNodes.clear()
744
+ this._brokenSnapshotWrites.clear()
745
+ this._trackResolveQueue.length = 0
746
+ await Promise.allSettled(deletions)
747
+ }
748
+
749
+ _resolveTrackWithLimit(task) {
750
+ return new Promise((resolve, reject) => {
751
+ const run = () => {
752
+ this._trackResolveActive++
753
+ Promise.resolve()
754
+ .then(task)
755
+ .then(resolve, reject)
756
+ .finally(() => {
757
+ this._trackResolveActive--
758
+ const next = this._trackResolveQueue.shift()
759
+ if (next) next()
760
+ })
761
+ }
762
+ if (this._trackResolveActive < this.aqua.trackResolveConcurrency) run()
763
+ else this._trackResolveQueue.push(run)
764
+ })
765
+ }
766
+
767
+ _applyVoiceBootstrap(player, voiceState) {
768
+ if (!voiceState || !player?.connection) return false
769
+ const connection = player.connection
770
+ connection.sessionId = voiceState.sid || connection.sessionId
771
+ connection.endpoint = voiceState.ep || connection.endpoint
772
+ connection.token = voiceState.tok || connection.token
773
+ connection.region = voiceState.reg || connection.region
774
+ connection.channelId = voiceState.cid || connection.channelId
775
+ connection._lastEndpoint = voiceState.ep || connection._lastEndpoint
776
+ if (!connection.sessionId || !connection.endpoint || !connection.token)
777
+ return false
778
+ connection._lastVoiceDataUpdate = Date.now()
779
+ connection.resendVoiceUpdate(true)
780
+ return true
781
+ }
782
+
783
+ _serializeBrokenPlayer(player, nodeId, brokenAt) {
784
+ const state = this.capturePlayerState(player)
785
+ if (!state) return null
786
+ const requester = player.requester || player.current?.requester
787
+ const connection = player.connection
788
+ return {
789
+ type: 'broken_player',
790
+ n: nodeId,
791
+ brokenAt,
792
+ g: state.guildId,
793
+ t: state.textChannel,
794
+ v: state.voiceChannel,
795
+ u: state.current?.uri || null,
796
+ p: state.position || 0,
797
+ q: (state.queue || [])
798
+ .slice(0, this.aqua.maxQueueSave)
799
+ .map((track) => track?.uri)
800
+ .filter(Boolean),
801
+ r: requester ? `${requester.id}:${requester.username}` : null,
802
+ vol: state.volume,
803
+ pa: state.paused,
804
+ pl: state.playing,
805
+ nw: player.nowPlayingMessage?.id || null,
806
+ d: state.deaf,
807
+ m: !!player.mute,
808
+ loop: state.loop,
809
+ sh: state.shuffle,
810
+ vs: connection
811
+ ? {
812
+ sid: connection.sessionId || null,
813
+ ep: connection.endpoint || null,
814
+ tok: connection.token || null,
815
+ reg: connection.region || null,
816
+ cid: connection.channelId || null
817
+ }
818
+ : null,
819
+ resuming: true
820
+ }
821
+ }
822
+
823
+ _getBrokenPlayerSnapshotPath(nodeId) {
824
+ const base = this.aqua.brokenPlayerStorePath
825
+ const ext = path.extname(base) || '.jsonl'
826
+ const dir = path.dirname(base)
827
+ const name = path.basename(base, ext)
828
+ const safeId = String(nodeId || 'unknown').replace(/[^a-z0-9._-]+/gi, '_')
829
+ return path.join(dir, `${name}.${safeId}${ext}`)
830
+ }
831
+
832
+ async _writeBrokenPlayerSnapshot(nodeId, records) {
833
+ const filePath = this._getBrokenPlayerSnapshotPath(nodeId)
834
+ if (!records.length) {
835
+ await this._deleteBrokenPlayerSnapshot(nodeId)
836
+ return
837
+ }
838
+ const tempFile = `${filePath}.tmp`
839
+ this._brokenSnapshotNodes.add(nodeId)
840
+ const writeTask = (async () => {
841
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
842
+ await fs.promises.writeFile(
843
+ tempFile,
844
+ `${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
845
+ 'utf8'
846
+ )
847
+ await fs.promises.rename(tempFile, filePath)
848
+ })()
849
+ this._brokenSnapshotWrites.set(nodeId, writeTask)
850
+ try {
851
+ await writeTask
852
+ } finally {
853
+ if (this._brokenSnapshotWrites.get(nodeId) === writeTask) {
854
+ this._brokenSnapshotWrites.delete(nodeId)
855
+ }
856
+ }
857
+ }
858
+
859
+ async _readBrokenPlayerSnapshot(nodeId, guildIds) {
860
+ const filePath = this._getBrokenPlayerSnapshotPath(nodeId)
861
+ let stream = null
862
+ let rl = null
863
+ const entries = []
864
+ try {
865
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' })
866
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
867
+ for await (const line of rl) {
868
+ if (!line.trim()) continue
869
+ try {
870
+ const parsed = JSON.parse(line)
871
+ if (
872
+ parsed?.type === 'broken_player' &&
873
+ parsed.n === nodeId &&
874
+ guildIds.has(String(parsed.g))
875
+ ) {
876
+ entries.push(parsed)
877
+ }
878
+ } catch {}
879
+ }
880
+ } catch (error) {
881
+ if (error?.code !== 'ENOENT') {
882
+ reportSuppressedError(this.aqua, 'aqua.brokenPlayers.read', error, {
883
+ node: nodeId
884
+ })
885
+ }
886
+ } finally {
887
+ if (rl) this._functions.safeCall(() => rl.close())
888
+ if (stream) this._functions.safeCall(() => stream.destroy())
889
+ }
890
+ return entries
891
+ }
892
+
893
+ async _deleteBrokenPlayerSnapshot(nodeId) {
894
+ const filePath = this._getBrokenPlayerSnapshotPath(nodeId)
895
+ this._brokenSnapshotNodes.delete(nodeId)
896
+ await fs.promises.unlink(filePath).catch(this._functions.noop)
897
+ await fs.promises.unlink(`${filePath}.tmp`).catch(this._functions.noop)
898
+ }
899
+ }
900
+
901
+ module.exports = AquaRecovery