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