aqualink 2.18.1 → 2.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,897 +1,1086 @@
1
- 'use strict'
2
-
3
- const fs = require('node:fs')
4
- const readline = require('node:readline')
5
- const { EventEmitter } = require('tseep')
6
- const { AqualinkEvents } = require('./AqualinkEvents')
7
- const Node = require('./Node')
8
- const Player = require('./Player')
9
- const Track = require('./Track')
10
- const { version: pkgVersion } = require('../../package.json')
11
-
12
- const SEARCH_PREFIX = ':'
13
- const EMPTY_ARRAY = Object.freeze([])
14
- const EMPTY_TRACKS_RESPONSE = Object.freeze({
15
- loadType: 'empty',
16
- exception: null,
17
- playlistInfo: null,
18
- pluginInfo: {},
19
- tracks: EMPTY_ARRAY
20
- })
21
-
22
- const MAX_CONCURRENT_OPS = 10
23
- const BROKEN_PLAYER_TTL = 300000
24
- const FAILOVER_CLEANUP_TTL = 600000
25
- const PLAYER_BATCH_SIZE = 20
26
- const SEEK_DELAY = 120
27
- const RECONNECT_DELAY = 400
28
- const CACHE_VALID_TIME = 12000
29
- const NODE_TIMEOUT = 30000
30
- const MAX_CACHE_SIZE = 20
31
- const MAX_FAILOVER_QUEUE = 50
32
- const MAX_REBUILD_LOCKS = 100
33
- const WRITE_BUFFER_SIZE = 100
34
- const MAX_QUEUE_SAVE = 10
35
- const MAX_TRACKS_RESTORE = 20
36
-
37
- const DEFAULT_OPTIONS = Object.freeze({
38
- shouldDeleteMessage: false,
39
- defaultSearchPlatform: 'ytsearch',
40
- leaveOnEnd: false,
41
- restVersion: 'v4',
42
- plugins: [],
43
- autoResume: true,
44
- infiniteReconnects: true,
45
- loadBalancer: 'leastLoad',
46
- useHttp2: false,
47
- failoverOptions: Object.freeze({
48
- enabled: true,
49
- maxRetries: 3,
50
- retryDelay: 1000,
51
- preservePosition: true,
52
- resumePlayback: true,
53
- cooldownTime: 5000,
54
- maxFailoverAttempts: 5
55
- }),
56
- maxQueueSave: 10,
57
- maxTracksRestore: 20
58
- })
59
-
60
- const _functions = {
61
- delay: ms => new Promise(r => {
62
- const t = setTimeout(r, ms)
63
- t.unref?.()
64
- }),
65
- noop: () => { },
66
- isUrl: query => {
67
- if (typeof query !== 'string' || query.length <= 8) return false
68
- const q = query.trimStart()
69
- return q.startsWith('http://') || q.startsWith('https://')
70
- },
71
- formatQuery(query, source) {
72
- return this.isUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}`
73
- },
74
- makeTrack: (t, requester, node) => new Track(t, requester, node),
75
- safeCall(fn) {
76
- try {
77
- const result = fn()
78
- return result?.then ? result.catch(this.noop) : result
79
- } catch { }
80
- },
81
- parseRequester(str) {
82
- if (!str || typeof str !== 'string') return null
83
- const i = str.indexOf(':')
84
- return i > 0 ? { id: str.substring(0, i), username: str.substring(i + 1) } : null
85
- },
86
- unrefTimeout: (fn, ms) => {
87
- const t = setTimeout(fn, ms)
88
- t.unref?.()
89
- return t
90
- }
91
- }
92
-
93
- class Aqua extends EventEmitter {
94
- constructor(client, nodes, options = {}) {
95
- super()
96
- if (!client) throw new Error('Client is required')
97
- if (!Array.isArray(nodes) || !nodes.length) throw new TypeError('Nodes must be non-empty Array')
98
-
99
- this.client = client
100
- this.nodes = nodes
101
- this.nodeMap = new Map()
102
- this.players = new Map()
103
- this.clientId = null
104
- this.initiated = false
105
- this.version = pkgVersion
106
-
107
- const merged = { ...DEFAULT_OPTIONS, ...options }
108
- this.options = merged
109
- this.failoverOptions = { ...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions }
110
-
111
- this.shouldDeleteMessage = merged.shouldDeleteMessage
112
- this.defaultSearchPlatform = merged.defaultSearchPlatform
113
- this.leaveOnEnd = merged.leaveOnEnd
114
- this.restVersion = merged.restVersion || 'v4'
115
- this.plugins = merged.plugins
116
- this.autoResume = merged.autoResume
117
- this.infiniteReconnects = merged.infiniteReconnects
118
- this.urlFilteringEnabled = merged.urlFilteringEnabled
119
- this.restrictedDomains = merged.restrictedDomains || []
120
- this.allowedDomains = merged.allowedDomains || []
121
- this.loadBalancer = merged.loadBalancer
122
- this.useHttp2 = merged.useHttp2
123
- this.maxQueueSave = merged.maxQueueSave
124
- this.maxTracksRestore = merged.maxTracksRestore
125
- this.send = merged.send || this._createDefaultSend()
126
-
127
- this._nodeStates = new Map()
128
- this._failoverQueue = new Map()
129
- this._lastFailoverAttempt = new Map()
130
- this._brokenPlayers = new Map()
131
- this._rebuildLocks = new Set()
132
- this._leastUsedNodesCache = null
133
- this._leastUsedNodesCacheTime = 0
134
- this._nodeLoadCache = new Map()
135
- this._eventHandlers = null
136
- this._loading = false
137
-
138
- if (this.autoResume) this._bindEventHandlers()
139
- }
140
-
141
- _createDefaultSend() {
142
- return packet => {
143
- const guildId = packet?.d?.guild_id
144
- if (!guildId) return
145
- const guild = this.client.guilds?.cache?.get?.(guildId) || this.client.cache?.guilds?.get?.(guildId)
146
- if (!guild) return
147
- const gateway = this.client.gateway
148
- if (gateway?.send) gateway.send(gateway.calculateShardId(guildId), packet)
149
- else if (guild.shard?.send) guild.shard.send(packet)
150
- }
151
- }
152
-
153
- _bindEventHandlers() {
154
- this._eventHandlers = {
155
- onNodeConnect: async node => {
156
- this._invalidateCache()
157
- await this._rebuildBrokenPlayers(node)
158
- this._performCleanup()
159
- },
160
- onNodeDisconnect: node => {
161
- this._invalidateCache()
162
- queueMicrotask(() => {
163
- this._storeBrokenPlayers(node)
164
- this._performCleanup()
165
- })
166
- },
167
- onNodeReady: (node, { resumed }) => {
168
- if (!resumed) return
169
- const batch = []
170
- for (const player of this.players.values()) {
171
- if (player.nodes === node && player.connection) batch.push(player)
172
- }
173
- if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate()))
174
- }
175
- }
176
- this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
177
- this.on(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
178
- this.on(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
179
- }
180
-
181
- destroy() {
182
- if (this._eventHandlers) {
183
- this.off(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
184
- this.off(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
185
- this.off(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
186
- this._eventHandlers = null
187
- }
188
- this.removeAllListeners()
189
-
190
- for (const id of Array.from(this.nodeMap.keys())) this._destroyNode(id)
191
- for (const player of Array.from(this.players.values())) _functions.safeCall(() => player.destroy())
192
-
193
- this.players.clear()
194
- this._nodeStates.clear()
195
- this._failoverQueue.clear()
196
- this._lastFailoverAttempt.clear()
197
- this._brokenPlayers.clear()
198
- this._rebuildLocks.clear()
199
- this._nodeLoadCache.clear()
200
- this._invalidateCache()
201
- }
202
-
203
- get leastUsedNodes() {
204
- const now = Date.now()
205
- if (this._leastUsedNodesCache && (now - this._leastUsedNodesCacheTime) < CACHE_VALID_TIME) {
206
- return this._leastUsedNodesCache
207
- }
208
- const connected = []
209
- for (const n of this.nodeMap.values()) {
210
- if (n.connected) connected.push(n)
211
- }
212
- let sorted
213
- if (this.loadBalancer === 'leastRest') {
214
- sorted = connected.sort((a, b) => (a.rest?.calls || 0) - (b.rest?.calls || 0))
215
- } else if (this.loadBalancer === 'random') {
216
- sorted = connected.sort(() => Math.random() - 0.5)
217
- } else {
218
- const withLoads = connected.map(n => ({ node: n, load: this._getNodeLoad(n) }))
219
- withLoads.sort((a, b) => a.load - b.load)
220
- sorted = withLoads.map(x => x.node)
221
- }
222
- this._leastUsedNodesCache = Object.freeze(sorted)
223
- this._leastUsedNodesCacheTime = now
224
- return this._leastUsedNodesCache
225
- }
226
-
227
- _invalidateCache() {
228
- this._leastUsedNodesCache = null
229
- this._leastUsedNodesCacheTime = 0
230
- }
231
-
232
- _getNodeLoad(node) {
233
- const id = node.name || node.host
234
- const now = Date.now()
235
- const cached = this._nodeLoadCache.get(id)
236
- if (cached && (now - cached.time) < 5000) return cached.load
237
- const stats = node?.stats
238
- if (!stats) return 0
239
- const cores = Math.max(1, stats.cpu?.cores || 1)
240
- const reservable = Math.max(1, stats.memory?.reservable || 1)
241
- const load = (stats.cpu ? stats.cpu.systemLoad / cores : 0) * 100 +
242
- (stats.playingPlayers || 0) * 0.75 +
243
- (stats.memory ? stats.memory.used / reservable : 0) * 40 +
244
- (node.rest?.calls || 0) * 0.001
245
- this._nodeLoadCache.set(id, { load, time: now })
246
- if (this._nodeLoadCache.size > MAX_CACHE_SIZE) {
247
- const first = this._nodeLoadCache.keys().next().value
248
- this._nodeLoadCache.delete(first)
249
- }
250
- return load
251
- }
252
-
253
- async init(clientId) {
254
- if (clientId) {
255
- const newId = String(clientId)
256
- if (this.clientId !== newId) {
257
- this.clientId = newId
258
- }
259
- }
260
-
261
- if (this.initiated) return this
262
- if (!this.clientId) return this
263
- await this._loadNodeSessions().catch(() => { })
264
- const results = await Promise.allSettled(
265
- this.nodes.map(n => Promise.race([this._createNode(n), _functions.delay(NODE_TIMEOUT).then(() => { throw new Error('Timeout') })]))
266
- )
267
- if (!results.some(r => r.status === 'fulfilled')) throw new Error('No nodes connected')
268
- if (this.plugins?.length) {
269
- await Promise.allSettled(this.plugins.map(p => _functions.safeCall(() => p.load(this))))
270
- }
271
- this.initiated = true
272
- return this
273
- }
274
-
275
- async _createNode(options) {
276
- const id = options.name || options.host
277
- this._destroyNode(id)
278
- const node = new Node(this, options, this.options)
279
- node.players = new Set()
280
- this.nodeMap.set(id, node)
281
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
282
- try {
283
- await node.connect()
284
- this._nodeStates.set(id, { connected: true, failoverInProgress: false })
285
- this._invalidateCache()
286
- this.emit(AqualinkEvents.NodeCreate, node)
287
- return node
288
- } catch (error) {
289
- this._cleanupNode(id)
290
- throw error
291
- }
292
- }
293
-
294
- _destroyNode(id) {
295
- const node = this.nodeMap.get(id)
296
- if (!node) return
297
- _functions.safeCall(() => node.destroy())
298
- this._cleanupNode(id)
299
- this.emit(AqualinkEvents.NodeDestroy, node)
300
- }
301
-
302
- _cleanupNode(id) {
303
- const node = this.nodeMap.get(id)
304
- if (node) {
305
- _functions.safeCall(() => node.removeAllListeners())
306
- _functions.safeCall(() => node.players.clear())
307
- this.nodeMap.delete(id)
308
- }
309
- this._nodeStates.delete(id)
310
- this._failoverQueue.delete(id)
311
- this._lastFailoverAttempt.delete(id)
312
- this._nodeLoadCache.delete(id)
313
- this._invalidateCache()
314
- }
315
-
316
- _storeBrokenPlayers(node) {
317
- const id = node.name || node.host
318
- const now = Date.now()
319
- for (const player of this.players.values()) {
320
- if (player.nodes !== node) continue
321
- const state = this._capturePlayerState(player)
322
- if (state) {
323
- state.originalNodeId = id
324
- state.brokenAt = now
325
- this._brokenPlayers.set(String(player.guildId), state)
326
- }
327
- }
328
- }
329
-
330
- async _rebuildBrokenPlayers(node) {
331
- const id = node.name || node.host
332
- const rebuilds = []
333
- const now = Date.now()
334
- for (const [guildId, state] of this._brokenPlayers) {
335
- if (state.originalNodeId === id && (now - state.brokenAt) < BROKEN_PLAYER_TTL) {
336
- rebuilds.push({ guildId, state })
337
- }
338
- }
339
- if (!rebuilds.length) return
340
- const successes = []
341
- for (let i = 0; i < rebuilds.length; i += MAX_CONCURRENT_OPS) {
342
- const batch = rebuilds.slice(i, i + MAX_CONCURRENT_OPS)
343
- const results = await Promise.allSettled(
344
- batch.map(({ guildId, state }) => this._rebuildPlayer(state, node).then(() => guildId))
345
- )
346
- for (const r of results) {
347
- if (r.status === 'fulfilled') successes.push(r.value)
348
- }
349
- }
350
- for (const guildId of successes) this._brokenPlayers.delete(guildId)
351
- if (successes.length) this.emit(AqualinkEvents.PlayersRebuilt, node, successes.length)
352
- this._performCleanup()
353
- }
354
-
355
- async _rebuildPlayer(state, targetNode) {
356
- const { guildId, textChannel, voiceChannel, current, volume = 65, deaf = true } = state
357
- const lockKey = `rebuild_${guildId}`
358
- if (this._rebuildLocks.has(lockKey)) return
359
- this._rebuildLocks.add(lockKey)
360
- try {
361
- if (this.players.has(guildId)) {
362
- await this.destroyPlayer(guildId)
363
- await _functions.delay(RECONNECT_DELAY)
364
- }
365
- const player = this.createPlayer(targetNode, { guildId, textChannel, voiceChannel, defaultVolume: volume, deaf })
366
- if (current && player?.queue?.add) {
367
- player.queue.add(current)
368
- await player.play()
369
- if (state.position > 0) {
370
- const seekOnce = (p) => {
371
- if (p.guildId === guildId) {
372
- this.off(AqualinkEvents.TrackStart, seekOnce)
373
- _functions.unrefTimeout(() => player.seek?.(state.position), 50)
374
- }
375
- }
376
- this.once(AqualinkEvents.TrackStart, seekOnce)
377
- player.once('destroy', () => this.off(AqualinkEvents.TrackStart, seekOnce))
378
- }
379
- if (state.paused) player.pause(true)
380
- }
381
- return player
382
- } finally {
383
- this._rebuildLocks.delete(lockKey)
384
- }
385
- }
386
-
387
- async handleNodeFailover(failedNode) {
388
- if (!this.failoverOptions.enabled) return
389
- const id = failedNode.name || failedNode.host
390
- const now = Date.now()
391
- const state = this._nodeStates.get(id)
392
- if (state?.failoverInProgress) return
393
- const lastAttempt = this._lastFailoverAttempt.get(id)
394
- if (lastAttempt && (now - lastAttempt) < this.failoverOptions.cooldownTime) return
395
- const attempts = this._failoverQueue.get(id) || 0
396
- if (attempts >= this.failoverOptions.maxFailoverAttempts) return
397
-
398
- this._nodeStates.set(id, { connected: false, failoverInProgress: true })
399
- this._lastFailoverAttempt.set(id, now)
400
- this._failoverQueue.set(id, attempts + 1)
401
-
402
- try {
403
- this.emit(AqualinkEvents.NodeFailover, failedNode)
404
- const players = Array.from(failedNode.players || [])
405
- if (!players.length) return
406
- const available = []
407
- for (const n of this.nodeMap.values()) {
408
- if (n !== failedNode && n.connected) available.push(n)
409
- }
410
- if (!available.length) throw new Error('No failover nodes')
411
- const results = await this._migratePlayersOptimized(players, available)
412
- const successful = results.filter(r => r.success).length
413
- if (successful) {
414
- this.emit(AqualinkEvents.NodeFailoverComplete, failedNode, successful, results.length - successful)
415
- this._performCleanup()
416
- }
417
- } catch (error) {
418
- this.emit(AqualinkEvents.Error, null, error)
419
- } finally {
420
- this._nodeStates.set(id, { connected: false, failoverInProgress: false })
421
- }
422
- }
423
-
424
- async _migratePlayersOptimized(players, nodes) {
425
- const loads = new Map()
426
- const counts = new Map()
427
- for (const n of nodes) {
428
- loads.set(n, this._getNodeLoad(n))
429
- counts.set(n, 0)
430
- }
431
- const pickNode = () => {
432
- let best = nodes[0], bestScore = loads.get(best) + counts.get(best)
433
- for (let i = 1; i < nodes.length; i++) {
434
- const score = loads.get(nodes[i]) + counts.get(nodes[i])
435
- if (score < bestScore) { best = nodes[i]; bestScore = score }
436
- }
437
- counts.set(best, counts.get(best) + 1)
438
- return best
439
- }
440
- const results = []
441
- for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
442
- const batch = players.slice(i, i + MAX_CONCURRENT_OPS)
443
- const batchResults = await Promise.allSettled(batch.map(p => this._migratePlayer(p, pickNode)))
444
- for (const r of batchResults) results.push({ success: r.status === 'fulfilled', error: r.reason })
445
- }
446
- return results
447
- }
448
-
449
- async _migratePlayer(player, pickNode) {
450
- const state = this._capturePlayerState(player)
451
- if (!state) throw new Error('Failed to capture state')
452
- const { maxRetries, retryDelay } = this.failoverOptions
453
- for (let retry = 0; retry < maxRetries; retry++) {
454
- try {
455
- const targetNode = pickNode()
456
- const newPlayer = this._createPlayerOnNode(targetNode, state)
457
- await this._restorePlayerState(newPlayer, state)
458
- this.emit(AqualinkEvents.PlayerMigrated, player, newPlayer, targetNode)
459
- return newPlayer
460
- } catch (error) {
461
- if (retry === maxRetries - 1) throw error
462
- await _functions.delay(retryDelay * Math.pow(1.5, retry))
463
- }
464
- }
465
- }
466
-
467
- _capturePlayerState(player) {
468
- if (!player) return null
469
- let position = player.position || 0
470
- if (player.playing && !player.paused && player.timestamp) {
471
- const elapsed = Date.now() - player.timestamp
472
- position = Math.min(position + elapsed, player.current?.info?.length || position + elapsed)
473
- }
474
- return {
475
- guildId: player.guildId,
476
- textChannel: player.textChannel,
477
- voiceChannel: player.voiceChannel,
478
- volume: player.volume ?? 100,
479
- paused: !!player.paused,
480
- position,
481
- current: player.current || null,
482
- queue: player.queue?.toArray?.() || EMPTY_ARRAY,
483
- loop: player.loop,
484
- shuffle: player.shuffle,
485
- deaf: player.deaf ?? false,
486
- connected: !!player.connected
487
- }
488
- }
489
-
490
- _createPlayerOnNode(targetNode, state) {
491
- return this.createPlayer(targetNode, {
492
- guildId: state.guildId,
493
- textChannel: state.textChannel,
494
- voiceChannel: state.voiceChannel,
495
- defaultVolume: state.volume || 100,
496
- deaf: state.deaf || false
497
- })
498
- }
499
-
500
- async _restorePlayerState(newPlayer, state) {
501
- const ops = []
502
- if (typeof state.volume === 'number') {
503
- if (typeof newPlayer.setVolume === 'function') ops.push(newPlayer.setVolume(state.volume))
504
- else newPlayer.volume = state.volume
505
- }
506
- if (state.queue?.length && newPlayer.queue?.add) newPlayer.queue.add(...state.queue)
507
- if (state.current && this.failoverOptions.preservePosition) {
508
- newPlayer.queue?.add?.(state.current, { toFront: true })
509
- if (this.failoverOptions.resumePlayback) {
510
- ops.push(newPlayer.play())
511
- if (state.position > 0) {
512
- const guildId = newPlayer.guildId
513
- const seekOnce = (p) => {
514
- if (p.guildId === guildId) {
515
- this.off(AqualinkEvents.TrackStart, seekOnce)
516
- _functions.unrefTimeout(() => newPlayer.seek?.(state.position), 50)
517
- }
518
- }
519
- this.once(AqualinkEvents.TrackStart, seekOnce)
520
- newPlayer.once('destroy', () => this.off(AqualinkEvents.TrackStart, seekOnce))
521
- }
522
- if (state.paused) ops.push(newPlayer.pause(true))
523
- }
524
- }
525
- newPlayer.loop = state.loop
526
- newPlayer.shuffle = state.shuffle
527
- await Promise.allSettled(ops)
528
- }
529
-
530
- updateVoiceState({ d, t }) {
531
- if (!d?.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')) return
532
- const player = this.players.get(String(d.guild_id))
533
- if (!player) return
534
-
535
- d.txId = player.txId
536
- if (t === 'VOICE_STATE_UPDATE') {
537
- if (d.user_id !== this.clientId) return
538
- if (player.connection) {
539
- if (!d.channel_id && player.connection.voiceChannel) {
540
- player.connection.setStateUpdate(d)
541
- } else {
542
- player.connection.sessionId = d.session_id
543
- player.connection.setStateUpdate(d)
544
- }
545
- }
546
- } else {
547
- player.connection?.setServerUpdate(d)
548
- }
549
- }
550
-
551
- fetchRegion(region) {
552
- if (!region) return this.leastUsedNodes
553
- const lower = region.toLowerCase()
554
- const filtered = []
555
- for (const n of this.nodeMap.values()) {
556
- if (n.connected && n.regions?.includes(lower)) filtered.push(n)
557
- }
558
- return Object.freeze(filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b)))
559
- }
560
-
561
- createConnection(options) {
562
- if (!this.initiated) throw new Error('Aqua not initialized')
563
- const existing = this.players.get(String(options.guildId))
564
- if (existing && !existing.destroyed) {
565
- if (options.voiceChannel && existing.voiceChannel !== options.voiceChannel) {
566
- _functions.safeCall(() => existing.connect(options))
567
- }
568
- return existing
569
- }
570
- const candidates = options.region ? this.fetchRegion(options.region) : this.leastUsedNodes
571
- if (!candidates.length) throw new Error('No nodes available')
572
- return this.createPlayer(this._chooseLeastBusyNode(candidates), options)
573
- }
574
-
575
- createPlayer(node, options) {
576
- const existing = this.players.get(options.guildId)
577
- if (existing) {
578
- _functions.safeCall(() => existing.destroy({
579
- preserveMessage: options.preserveMessage || !!options.resuming || false,
580
- preserveTracks: !!options.resuming || false
581
- }))
582
- }
583
- const player = new Player(this, node, options)
584
- const guildId = String(options.guildId)
585
- this.players.set(guildId, player)
586
- node?.players?.add?.(player)
587
- player.once('destroy', () => this._handlePlayerDestroy(player))
588
- player.connect(options)
589
- this.emit(AqualinkEvents.PlayerCreate, player)
590
- return player
591
- }
592
-
593
- _handlePlayerDestroy(player) {
594
- player.nodes?.players?.delete?.(player)
595
- const guildId = String(player.guildId)
596
- if (this.players.get(guildId) === player) this.players.delete(guildId)
597
- this.emit(AqualinkEvents.PlayerDestroyed, player)
598
- }
599
-
600
- async destroyPlayer(guildId) {
601
- const id = String(guildId)
602
- const player = this.players.get(id)
603
- if (!player) return
604
- this.players.delete(id)
605
- _functions.safeCall(() => player.removeAllListeners())
606
- await _functions.safeCall(() => player.destroy())
607
- }
608
-
609
- async resolve({ query, source, requester, nodes }) {
610
- if (!this.initiated) throw new Error('Aqua not initialized')
611
- const node = this._getRequestNode(nodes)
612
- if (!node) throw new Error('No nodes available')
613
- const formatted = _functions.formatQuery(query, source || this.defaultSearchPlatform)
614
- const endpoint = `/${this.restVersion}/loadtracks?identifier=${encodeURIComponent(formatted)}`
615
- try {
616
- const response = await node.rest.makeRequest('GET', endpoint)
617
- if (!response || response.loadType === 'empty' || response.loadType === 'NO_MATCHES') return EMPTY_TRACKS_RESPONSE
618
- return this._constructResponse(response, requester, node)
619
- } catch (error) {
620
- throw new Error(error?.name === 'AbortError' ? 'Request timeout' : `Resolve failed: ${error?.message || error}`)
621
- }
622
- }
623
-
624
- _getRequestNode(nodes) {
625
- if (!nodes) return this._chooseLeastBusyNode(this.leastUsedNodes)
626
- if (nodes instanceof Node) return nodes
627
- if (Array.isArray(nodes)) {
628
- const candidates = nodes.filter(n => n?.connected)
629
- return this._chooseLeastBusyNode(candidates.length ? candidates : this.leastUsedNodes)
630
- }
631
- if (typeof nodes === 'string') {
632
- const node = this.nodeMap.get(nodes)
633
- return node?.connected ? node : this._chooseLeastBusyNode(this.leastUsedNodes)
634
- }
635
- throw new TypeError(`Invalid nodes: ${typeof nodes}`)
636
- }
637
-
638
- _chooseLeastBusyNode(nodes) {
639
- if (!nodes?.length) return null
640
- if (nodes.length === 1) return nodes[0]
641
- let best = nodes[0], bestScore = this._getNodeLoad(best)
642
- for (let i = 1; i < nodes.length; i++) {
643
- const score = this._getNodeLoad(nodes[i])
644
- if (score < bestScore) { best = nodes[i]; bestScore = score }
645
- }
646
- return best
647
- }
648
-
649
- _constructResponse(response, requester, node) {
650
- const { loadType, data, pluginInfo: rootPlugin } = response || {}
651
- const base = { loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: [] }
652
- if (loadType === 'error' || loadType === 'LOAD_FAILED') {
653
- base.exception = data || response.exception || null
654
- return base
655
- }
656
- if (loadType === 'track' && data) {
657
- base.pluginInfo = data.info?.pluginInfo || data.pluginInfo || base.pluginInfo
658
- base.tracks.push(_functions.makeTrack(data, requester, node))
659
- } else if (loadType === 'playlist' && data) {
660
- const info = data.info
661
- if (info) {
662
- base.playlistInfo = {
663
- name: info.name || info.title,
664
- thumbnail: data.pluginInfo?.artworkUrl || data.tracks?.[0]?.info?.artworkUrl || null,
665
- ...info
666
- }
667
- }
668
- base.pluginInfo = data.pluginInfo || rootPlugin || base.pluginInfo
669
- base.tracks = Array.isArray(data.tracks) ? data.tracks.map(t => _functions.makeTrack(t, requester, node)) : []
670
- } else if (loadType === 'search') {
671
- base.tracks = Array.isArray(data) ? data.map(t => _functions.makeTrack(t, requester, node)) : []
672
- }
673
- return base
674
- }
675
-
676
- get(guildId) {
677
- const player = this.players.get(String(guildId))
678
- if (!player) throw new Error(`Player not found: ${guildId}`)
679
- return player
680
- }
681
-
682
- async search(query, requester, source) {
683
- if (!query || !requester) return null
684
- try {
685
- const { tracks } = await this.resolve({ query, source: source || this.defaultSearchPlatform, requester })
686
- return tracks || null
687
- } catch {
688
- return null
689
- }
690
- }
691
-
692
- async savePlayer(filePath = './AquaPlayers.jsonl') {
693
- const lockFile = `${filePath}.lock`
694
- const tempFile = `${filePath}.tmp`
695
- let ws = null
696
- try {
697
- await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
698
- ws = fs.createWriteStream(tempFile, { encoding: 'utf8', flags: 'w' })
699
- const buffer = []
700
- let drainPromise = Promise.resolve()
701
-
702
- const nodeSessions = {}
703
- for (const node of this.nodeMap.values()) {
704
- if (node.sessionId) nodeSessions[node.name] = node.sessionId
705
- }
706
- buffer.push(JSON.stringify({ type: 'node_sessions', data: nodeSessions }))
707
-
708
- for (const player of this.players.values()) {
709
- const requester = player.requester || player.current?.requester
710
- const data = {
711
- g: player.guildId,
712
- t: player.textChannel,
713
- v: player.voiceChannel,
714
- u: player.current?.uri || null,
715
- p: player.position || 0,
716
- ts: player.timestamp || 0,
717
- q: player.queue.toArray().slice(0, this.maxQueueSave).map(tr => tr.uri),
718
- r: requester ? `${requester.id}:${requester.username}` : null,
719
- vol: player.volume,
720
- pa: player.paused,
721
- pl: player.playing,
722
- nw: player.nowPlayingMessage?.id || null,
723
- resuming: true
724
- }
725
- buffer.push(JSON.stringify(data))
726
-
727
- if (buffer.length >= WRITE_BUFFER_SIZE) {
728
- const chunk = buffer.join('\n') + '\n'
729
- buffer.length = 0
730
- if (!ws.write(chunk)) {
731
- drainPromise = drainPromise.then(() => new Promise(r => ws.once('drain', r)))
732
- }
733
- }
734
- }
735
-
736
- if (buffer.length) ws.write(buffer.join('\n') + '\n')
737
- await drainPromise
738
- await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve()))
739
- ws = null
740
- await fs.promises.rename(tempFile, filePath)
741
- } catch (error) {
742
- console.error(`[Aqua/Autoresume]Error saving players:`, error)
743
- this.emit(AqualinkEvents.Error, null, error)
744
- if (ws) _functions.safeCall(() => ws.destroy())
745
- await fs.promises.unlink(tempFile).catch(_functions.noop)
746
- } finally {
747
- if (ws) _functions.safeCall(() => ws.destroy())
748
- await fs.promises.unlink(lockFile).catch(_functions.noop)
749
- }
750
- }
751
-
752
- async loadPlayers(filePath = './AquaPlayers.jsonl') {
753
- if (this._loading) return
754
- this._loading = true
755
- const lockFile = `${filePath}.lock`
756
- let stream = null, rl = null
757
- try {
758
- await fs.promises.access(filePath)
759
- await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
760
- await this._waitForFirstNode()
761
-
762
- stream = fs.createReadStream(filePath, { encoding: 'utf8' })
763
- rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
764
-
765
- const batch = []
766
- for await (const line of rl) {
767
- if (!line.trim()) continue
768
- try {
769
- const parsed = JSON.parse(line)
770
- if (parsed.type === 'node_sessions') continue
771
- batch.push(parsed)
772
- } catch { continue }
773
- if (batch.length >= PLAYER_BATCH_SIZE) {
774
- await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
775
- batch.length = 0
776
- }
777
- }
778
- if (batch.length) await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
779
- await fs.promises.writeFile(filePath, '')
780
- } catch (err) {
781
- if (err.code !== 'ENOENT') {
782
- console.error(`[Aqua/Autoresume]Error loading players:`, err)
783
- this.emit(AqualinkEvents.Error, null, err)
784
- }
785
- } finally {
786
- this._loading = false
787
- if (rl) _functions.safeCall(() => rl.close())
788
- if (stream) _functions.safeCall(() => stream.destroy())
789
- await fs.promises.unlink(lockFile).catch(_functions.noop)
790
- }
791
- }
792
-
793
- async _restorePlayer(p) {
794
- try {
795
- const gId = String(p.g)
796
- const player = this.players.get(gId) || this.createPlayer(this._chooseLeastBusyNode(this.leastUsedNodes), {
797
- guildId: gId,
798
- textChannel: p.t,
799
- voiceChannel: p.v,
800
- defaultVolume: p.vol || 65,
801
- deaf: true,
802
- resuming: !!p.resuming
803
- })
804
- player._resuming = !!p.resuming
805
- const requester = _functions.parseRequester(p.r)
806
- const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0, this.maxTracksRestore)
807
- const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({ query: uri, requester }).catch(() => null)))
808
- const validTracks = resolved.flatMap(r => r?.tracks || [])
809
- if (validTracks.length && player.queue?.add) {
810
- player.queue.add(...validTracks)
811
- }
812
- if (p.u && validTracks[0]) {
813
- if (p.vol != null) {
814
- if (typeof player.setVolume === 'function') await player.setVolume(p.vol)
815
- else player.volume = p.vol
816
- }
817
- await player.play(undefined, { startTime: p.p, paused: p.pa })
818
- }
819
- if (p.nw && p.t) {
820
- const channel = this.client.channels?.cache?.get(p.t)
821
- if (channel?.messages) player.nowPlayingMessage = await channel.messages.fetch(p.nw).catch(() => null)
822
- }
823
- } catch (e) {
824
- console.error(`[Aqua/Autoresume]Failed to restore player for guild: ${p.g}`, e)
825
- }
826
- }
827
-
828
- async _waitForFirstNode(timeout = NODE_TIMEOUT) {
829
- if (this.leastUsedNodes.length) return
830
- return new Promise((resolve, reject) => {
831
- let resolved = false
832
- const cleanup = () => {
833
- if (resolved) return
834
- resolved = true
835
- clearTimeout(timer)
836
- this.off(AqualinkEvents.NodeConnect, onReady)
837
- this.off(AqualinkEvents.NodeCreate, onReady)
838
- }
839
- const onReady = () => {
840
- if (this.leastUsedNodes.length) { cleanup(); resolve() }
841
- }
842
- const timer = setTimeout(() => { cleanup(); reject(new Error('Timeout waiting for first node')) }, timeout)
843
- timer.unref?.()
844
- this.on(AqualinkEvents.NodeConnect, onReady)
845
- this.on(AqualinkEvents.NodeCreate, onReady)
846
- onReady()
847
- })
848
- }
849
-
850
- _performCleanup() {
851
- const now = Date.now()
852
- for (const [guildId, state] of this._brokenPlayers) {
853
- if (now - state.brokenAt > BROKEN_PLAYER_TTL) this._brokenPlayers.delete(guildId)
854
- }
855
- for (const [id, ts] of this._lastFailoverAttempt) {
856
- if (now - ts > FAILOVER_CLEANUP_TTL) {
857
- this._lastFailoverAttempt.delete(id)
858
- this._failoverQueue.delete(id)
859
- }
860
- }
861
- if (this._failoverQueue.size > MAX_FAILOVER_QUEUE) this._failoverQueue.clear()
862
- if (this._rebuildLocks.size > MAX_REBUILD_LOCKS) this._rebuildLocks.clear()
863
- for (const id of this._nodeStates.keys()) {
864
- if (!this.nodeMap.has(id)) this._nodeStates.delete(id)
865
- }
866
- }
867
-
868
- async _loadNodeSessions(filePath = './AquaPlayers.jsonl') {
869
- let stream = null, rl = null
870
- try {
871
- await fs.promises.access(filePath)
872
- stream = fs.createReadStream(filePath, { encoding: 'utf8' })
873
- rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
874
-
875
- for await (const line of rl) {
876
- if (!line.trim()) continue
877
- try {
878
- const parsed = JSON.parse(line)
879
- if (parsed.type === 'node_sessions') {
880
- for (const [name, sessionId] of Object.entries(parsed.data)) {
881
- const nodeOptions = this.nodes.find(n => (n.name || n.host) === name)
882
- if (nodeOptions) {
883
- nodeOptions.sessionId = sessionId
884
- }
885
- }
886
- break
887
- }
888
- } catch { continue }
889
- }
890
- } catch { } finally {
891
- if (rl) _functions.safeCall(() => rl.close())
892
- if (stream) _functions.safeCall(() => stream.destroy())
893
- }
894
- }
895
- }
896
-
1
+ const fs = require('node:fs')
2
+ const readline = require('node:readline')
3
+ const { EventEmitter } = require('tseep')
4
+ const { AqualinkEvents } = require('./AqualinkEvents')
5
+ const Node = require('./Node')
6
+ const Player = require('./Player')
7
+ const Track = require('./Track')
8
+ const { version: pkgVersion } = require('../../package.json')
9
+
10
+ const SEARCH_PREFIX = ':'
11
+ const EMPTY_ARRAY = Object.freeze([])
12
+ const EMPTY_TRACKS_RESPONSE = Object.freeze({
13
+ loadType: 'empty',
14
+ exception: null,
15
+ playlistInfo: null,
16
+ pluginInfo: {},
17
+ tracks: EMPTY_ARRAY
18
+ })
19
+
20
+ const MAX_CONCURRENT_OPS = 10
21
+ const BROKEN_PLAYER_TTL = 300000
22
+ const FAILOVER_CLEANUP_TTL = 600000
23
+ const PLAYER_BATCH_SIZE = 20
24
+ const RECONNECT_DELAY = 400
25
+ const CACHE_VALID_TIME = 12000
26
+ const NODE_TIMEOUT = 30000
27
+ const MAX_CACHE_SIZE = 20
28
+ const MAX_FAILOVER_QUEUE = 50
29
+ const MAX_REBUILD_LOCKS = 100
30
+ const WRITE_BUFFER_SIZE = 100
31
+
32
+ const DEFAULT_OPTIONS = Object.freeze({
33
+ shouldDeleteMessage: false,
34
+ defaultSearchPlatform: 'ytsearch',
35
+ leaveOnEnd: false,
36
+ restVersion: 'v4',
37
+ plugins: [],
38
+ autoResume: true,
39
+ infiniteReconnects: true,
40
+ loadBalancer: 'leastLoad',
41
+ useHttp2: false,
42
+ failoverOptions: Object.freeze({
43
+ enabled: true,
44
+ maxRetries: 3,
45
+ retryDelay: 1000,
46
+ preservePosition: true,
47
+ resumePlayback: true,
48
+ cooldownTime: 5000,
49
+ maxFailoverAttempts: 5
50
+ }),
51
+ maxQueueSave: 10,
52
+ maxTracksRestore: 20
53
+ })
54
+
55
+ const _functions = {
56
+ delay: (ms) =>
57
+ new Promise((r) => {
58
+ const t = setTimeout(r, ms)
59
+ t.unref?.()
60
+ }),
61
+ noop: () => {},
62
+ isUrl: (query) => {
63
+ if (typeof query !== 'string' || query.length <= 8) return false
64
+ const q = query.trimStart()
65
+ return q.startsWith('http://') || q.startsWith('https://')
66
+ },
67
+ formatQuery(query, source) {
68
+ return this.isUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}`
69
+ },
70
+ makeTrack: (t, requester, node) => new Track(t, requester, node),
71
+ safeCall(fn) {
72
+ try {
73
+ const result = fn()
74
+ return result?.then ? result.catch(this.noop) : result
75
+ } catch {}
76
+ },
77
+ parseRequester(str) {
78
+ if (!str || typeof str !== 'string') return null
79
+ const i = str.indexOf(':')
80
+ return i > 0
81
+ ? { id: str.substring(0, i), username: str.substring(i + 1) }
82
+ : null
83
+ },
84
+ unrefTimeout: (fn, ms) => {
85
+ const t = setTimeout(fn, ms)
86
+ t.unref?.()
87
+ return t
88
+ }
89
+ }
90
+
91
+ class Aqua extends EventEmitter {
92
+ constructor(client, nodes, options = {}) {
93
+ super()
94
+ if (!client) throw new Error('Client is required')
95
+ if (!Array.isArray(nodes) || !nodes.length)
96
+ throw new TypeError('Nodes must be non-empty Array')
97
+
98
+ this.client = client
99
+ this.nodes = nodes
100
+ this.nodeMap = new Map()
101
+ this.players = new Map()
102
+ this.clientId = null
103
+ this.initiated = false
104
+ this.version = pkgVersion
105
+
106
+ const merged = { ...DEFAULT_OPTIONS, ...options }
107
+ this.options = merged
108
+ this.failoverOptions = {
109
+ ...DEFAULT_OPTIONS.failoverOptions,
110
+ ...options.failoverOptions
111
+ }
112
+
113
+ this.shouldDeleteMessage = merged.shouldDeleteMessage
114
+ this.defaultSearchPlatform = merged.defaultSearchPlatform
115
+ this.leaveOnEnd = merged.leaveOnEnd
116
+ this.restVersion = merged.restVersion || 'v4'
117
+ this.plugins = merged.plugins
118
+ this.autoResume = merged.autoResume
119
+ this.infiniteReconnects = merged.infiniteReconnects
120
+ this.urlFilteringEnabled = merged.urlFilteringEnabled
121
+ this.restrictedDomains = merged.restrictedDomains || []
122
+ this.allowedDomains = merged.allowedDomains || []
123
+ this.loadBalancer = merged.loadBalancer
124
+ this.useHttp2 = merged.useHttp2
125
+ this.maxQueueSave = merged.maxQueueSave
126
+ this.maxTracksRestore = merged.maxTracksRestore
127
+ this.send = merged.send || this._createDefaultSend()
128
+
129
+ this._nodeStates = new Map()
130
+ this._failoverQueue = new Map()
131
+ this._lastFailoverAttempt = new Map()
132
+ this._brokenPlayers = new Map()
133
+ this._rebuildLocks = new Set()
134
+ this._leastUsedNodesCache = null
135
+ this._leastUsedNodesCacheTime = 0
136
+ this._nodeLoadCache = new Map()
137
+ this._eventHandlers = null
138
+ this._loading = false
139
+
140
+ if (this.autoResume) this._bindEventHandlers()
141
+ }
142
+
143
+ _createDefaultSend() {
144
+ return (packet) => {
145
+ const guildId = packet?.d?.guild_id
146
+ if (!guildId) return
147
+ const guild =
148
+ this.client.guilds?.cache?.get?.(guildId) ||
149
+ this.client.cache?.guilds?.get?.(guildId)
150
+ if (!guild) return
151
+ const gateway = this.client.gateway
152
+ if (gateway?.send) gateway.send(gateway.calculateShardId(guildId), packet)
153
+ else if (guild.shard?.send) guild.shard.send(packet)
154
+ }
155
+ }
156
+
157
+ _bindEventHandlers() {
158
+ this._eventHandlers = {
159
+ onNodeConnect: async (node) => {
160
+ this._invalidateCache()
161
+ await this._rebuildBrokenPlayers(node)
162
+ this._performCleanup()
163
+ },
164
+ onNodeDisconnect: (node) => {
165
+ this._invalidateCache()
166
+ queueMicrotask(() => {
167
+ this._storeBrokenPlayers(node)
168
+ this._performCleanup()
169
+ })
170
+ },
171
+ onNodeReady: (node, { resumed }) => {
172
+ if (!resumed) return
173
+ const batch = []
174
+ for (const player of this.players.values()) {
175
+ if (player.nodes === node && player.connection) batch.push(player)
176
+ }
177
+ if (batch.length)
178
+ queueMicrotask(() =>
179
+ batch.forEach((p) => {
180
+ p.connection.resendVoiceUpdate()
181
+ })
182
+ )
183
+ }
184
+ }
185
+ this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
186
+ this.on(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
187
+ this.on(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
188
+ }
189
+
190
+ destroy() {
191
+ if (this._eventHandlers) {
192
+ this.off(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
193
+ this.off(
194
+ AqualinkEvents.NodeDisconnect,
195
+ this._eventHandlers.onNodeDisconnect
196
+ )
197
+ this.off(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
198
+ this._eventHandlers = null
199
+ }
200
+ this.removeAllListeners()
201
+
202
+ for (const id of Array.from(this.nodeMap.keys())) this._destroyNode(id)
203
+ for (const player of Array.from(this.players.values()))
204
+ _functions.safeCall(() => player.destroy())
205
+
206
+ this.players.clear()
207
+ this._nodeStates.clear()
208
+ this._failoverQueue.clear()
209
+ this._lastFailoverAttempt.clear()
210
+ this._brokenPlayers.clear()
211
+ this._rebuildLocks.clear()
212
+ this._nodeLoadCache.clear()
213
+ this._invalidateCache()
214
+ }
215
+
216
+ get leastUsedNodes() {
217
+ const now = Date.now()
218
+ if (
219
+ this._leastUsedNodesCache &&
220
+ now - this._leastUsedNodesCacheTime < CACHE_VALID_TIME
221
+ ) {
222
+ return this._leastUsedNodesCache
223
+ }
224
+ const connected = []
225
+ for (const n of this.nodeMap.values()) {
226
+ if (n.connected) connected.push(n)
227
+ }
228
+ let sorted
229
+ if (this.loadBalancer === 'leastRest') {
230
+ sorted = connected.sort(
231
+ (a, b) => (a.rest?.calls || 0) - (b.rest?.calls || 0)
232
+ )
233
+ } else if (this.loadBalancer === 'random') {
234
+ sorted = connected.sort(() => Math.random() - 0.5)
235
+ } else {
236
+ const withLoads = connected.map((n) => ({
237
+ node: n,
238
+ load: this._getNodeLoad(n)
239
+ }))
240
+ withLoads.sort((a, b) => a.load - b.load)
241
+ sorted = withLoads.map((x) => x.node)
242
+ }
243
+ this._leastUsedNodesCache = Object.freeze(sorted)
244
+ this._leastUsedNodesCacheTime = now
245
+ return this._leastUsedNodesCache
246
+ }
247
+
248
+ _invalidateCache() {
249
+ this._leastUsedNodesCache = null
250
+ this._leastUsedNodesCacheTime = 0
251
+ }
252
+
253
+ _getNodeLoad(node) {
254
+ const id = node.name || node.host
255
+ const now = Date.now()
256
+ const cached = this._nodeLoadCache.get(id)
257
+ if (cached && now - cached.time < 5000) {
258
+ this._nodeLoadCache.delete(id)
259
+ this._nodeLoadCache.set(id, cached)
260
+ return cached.load
261
+ }
262
+ const stats = node?.stats
263
+ if (!stats) return 0
264
+ const cores = Math.max(1, stats.cpu?.cores || 1)
265
+ const reservable = Math.max(1, stats.memory?.reservable || 1)
266
+ const load =
267
+ (stats.cpu ? stats.cpu.systemLoad / cores : 0) * 100 +
268
+ (stats.playingPlayers || 0) * 0.75 +
269
+ (stats.memory ? stats.memory.used / reservable : 0) * 40 +
270
+ (node.rest?.calls || 0) * 0.001
271
+ if (this._nodeLoadCache.size >= MAX_CACHE_SIZE) {
272
+ const iterator = this._nodeLoadCache.keys()
273
+ while (this._nodeLoadCache.size >= MAX_CACHE_SIZE) {
274
+ const oldest = iterator.next().value
275
+ if (!oldest) break
276
+ this._nodeLoadCache.delete(oldest)
277
+ }
278
+ }
279
+ this._nodeLoadCache.set(id, { load, time: now })
280
+ return load
281
+ }
282
+
283
+ async init(clientId) {
284
+ if (clientId) {
285
+ const newId = String(clientId)
286
+ if (this.clientId !== newId) {
287
+ this.clientId = newId
288
+ }
289
+ }
290
+
291
+ if (this.initiated) return this
292
+ if (!this.clientId) return this
293
+ await this._loadNodeSessions().catch(() => {})
294
+ const results = await Promise.allSettled(
295
+ this.nodes.map((n) =>
296
+ Promise.race([
297
+ this._createNode(n),
298
+ _functions.delay(NODE_TIMEOUT).then(() => {
299
+ throw new Error('Timeout')
300
+ })
301
+ ])
302
+ )
303
+ )
304
+ if (!results.some((r) => r.status === 'fulfilled'))
305
+ throw new Error('No nodes connected')
306
+ if (this.plugins?.length) {
307
+ await Promise.allSettled(
308
+ this.plugins.map((p) => _functions.safeCall(() => p.load(this)))
309
+ )
310
+ }
311
+ this.initiated = true
312
+ return this
313
+ }
314
+
315
+ async _createNode(options) {
316
+ const id = options.name || options.host
317
+ this._destroyNode(id)
318
+ const node = new Node(this, options, this.options)
319
+ node.players = new Set()
320
+ this.nodeMap.set(id, node)
321
+ this._nodeStates.set(id, { connected: false, failoverInProgress: false })
322
+ try {
323
+ await node.connect()
324
+ this._nodeStates.set(id, { connected: true, failoverInProgress: false })
325
+ this._invalidateCache()
326
+ this.emit(AqualinkEvents.NodeCreate, node)
327
+ return node
328
+ } catch (error) {
329
+ this._cleanupNode(id)
330
+ throw error
331
+ }
332
+ }
333
+
334
+ _destroyNode(id) {
335
+ const node = this.nodeMap.get(id)
336
+ if (!node) return
337
+ _functions.safeCall(() => node.destroy())
338
+ this._cleanupNode(id)
339
+ this.emit(AqualinkEvents.NodeDestroy, node)
340
+ }
341
+
342
+ _cleanupNode(id) {
343
+ const node = this.nodeMap.get(id)
344
+ if (node) {
345
+ _functions.safeCall(() => node.removeAllListeners())
346
+ _functions.safeCall(() => node.players.clear())
347
+ this.nodeMap.delete(id)
348
+ }
349
+ this._nodeStates.delete(id)
350
+ this._failoverQueue.delete(id)
351
+ this._lastFailoverAttempt.delete(id)
352
+ this._nodeLoadCache.delete(id)
353
+ this._invalidateCache()
354
+ }
355
+
356
+ _storeBrokenPlayers(node) {
357
+ const id = node.name || node.host
358
+ const now = Date.now()
359
+ for (const player of this.players.values()) {
360
+ if (player.nodes !== node) continue
361
+ const state = this._capturePlayerState(player)
362
+ if (state) {
363
+ state.originalNodeId = id
364
+ state.brokenAt = now
365
+ this._brokenPlayers.set(String(player.guildId), state)
366
+ }
367
+ }
368
+ }
369
+
370
+ async _rebuildBrokenPlayers(node) {
371
+ const id = node.name || node.host
372
+ const rebuilds = []
373
+ const now = Date.now()
374
+ for (const [guildId, state] of this._brokenPlayers) {
375
+ if (
376
+ state.originalNodeId === id &&
377
+ now - state.brokenAt < BROKEN_PLAYER_TTL
378
+ ) {
379
+ rebuilds.push({ guildId, state })
380
+ }
381
+ }
382
+ if (!rebuilds.length) return
383
+ const successes = []
384
+ for (let i = 0; i < rebuilds.length; i += MAX_CONCURRENT_OPS) {
385
+ const batch = rebuilds.slice(i, i + MAX_CONCURRENT_OPS)
386
+ const results = await Promise.allSettled(
387
+ batch.map(({ guildId, state }) =>
388
+ this._rebuildPlayer(state, node).then(() => guildId)
389
+ )
390
+ )
391
+ for (const r of results) {
392
+ if (r.status === 'fulfilled') successes.push(r.value)
393
+ }
394
+ }
395
+ for (const guildId of successes) this._brokenPlayers.delete(guildId)
396
+ if (successes.length)
397
+ this.emit(AqualinkEvents.PlayersRebuilt, node, successes.length)
398
+ this._performCleanup()
399
+ }
400
+
401
+ async _rebuildPlayer(state, targetNode) {
402
+ const {
403
+ guildId,
404
+ textChannel,
405
+ voiceChannel,
406
+ current,
407
+ volume = 65,
408
+ deaf = true
409
+ } = state
410
+ const lockKey = `rebuild_${guildId}`
411
+ if (this._rebuildLocks.has(lockKey)) return
412
+ this._rebuildLocks.add(lockKey)
413
+ try {
414
+ if (this.players.has(guildId)) {
415
+ await this.destroyPlayer(guildId)
416
+ await _functions.delay(RECONNECT_DELAY)
417
+ }
418
+ const player = this.createPlayer(targetNode, {
419
+ guildId,
420
+ textChannel,
421
+ voiceChannel,
422
+ defaultVolume: volume,
423
+ deaf
424
+ })
425
+ if (current && player?.queue?.add) {
426
+ player.queue.add(current)
427
+ await player.play()
428
+ if (state.position > 0) {
429
+ const seekOnce = (p) => {
430
+ if (p.guildId === guildId) {
431
+ this.off(AqualinkEvents.TrackStart, seekOnce)
432
+ _functions.unrefTimeout(() => player.seek?.(state.position), 50)
433
+ }
434
+ }
435
+ this.once(AqualinkEvents.TrackStart, seekOnce)
436
+ player.once('destroy', () =>
437
+ this.off(AqualinkEvents.TrackStart, seekOnce)
438
+ )
439
+ }
440
+ if (state.paused) player.pause(true)
441
+ }
442
+ return player
443
+ } finally {
444
+ this._rebuildLocks.delete(lockKey)
445
+ }
446
+ }
447
+
448
+ async handleNodeFailover(failedNode) {
449
+ if (!this.failoverOptions.enabled) return
450
+ const id = failedNode.name || failedNode.host
451
+ const now = Date.now()
452
+ const state = this._nodeStates.get(id)
453
+ if (state?.failoverInProgress) return
454
+ const lastAttempt = this._lastFailoverAttempt.get(id)
455
+ if (lastAttempt && now - lastAttempt < this.failoverOptions.cooldownTime)
456
+ return
457
+ const attempts = this._failoverQueue.get(id) || 0
458
+ if (attempts >= this.failoverOptions.maxFailoverAttempts) return
459
+
460
+ this._nodeStates.set(id, { connected: false, failoverInProgress: true })
461
+ this._lastFailoverAttempt.set(id, now)
462
+ this._failoverQueue.set(id, attempts + 1)
463
+
464
+ try {
465
+ this.emit(AqualinkEvents.NodeFailover, failedNode)
466
+ const players = Array.from(failedNode.players || [])
467
+ if (!players.length) return
468
+ const available = []
469
+ for (const n of this.nodeMap.values()) {
470
+ if (n !== failedNode && n.connected) available.push(n)
471
+ }
472
+ if (!available.length) throw new Error('No failover nodes')
473
+ const results = await this._migratePlayersOptimized(players, available)
474
+ const successful = results.filter((r) => r.success).length
475
+ if (successful) {
476
+ this.emit(
477
+ AqualinkEvents.NodeFailoverComplete,
478
+ failedNode,
479
+ successful,
480
+ results.length - successful
481
+ )
482
+ this._performCleanup()
483
+ }
484
+ } catch (error) {
485
+ this.emit(AqualinkEvents.Error, null, error)
486
+ } finally {
487
+ this._nodeStates.set(id, { connected: false, failoverInProgress: false })
488
+ }
489
+ }
490
+
491
+ async _migratePlayersOptimized(players, nodes) {
492
+ const loads = new Map()
493
+ const counts = new Map()
494
+ for (const n of nodes) {
495
+ loads.set(n, this._getNodeLoad(n))
496
+ counts.set(n, 0)
497
+ }
498
+ const pickNode = () => {
499
+ let best = nodes[0],
500
+ bestScore = loads.get(best) + counts.get(best)
501
+ for (let i = 1; i < nodes.length; i++) {
502
+ const score = loads.get(nodes[i]) + counts.get(nodes[i])
503
+ if (score < bestScore) {
504
+ best = nodes[i]
505
+ bestScore = score
506
+ }
507
+ }
508
+ counts.set(best, counts.get(best) + 1)
509
+ return best
510
+ }
511
+ const results = []
512
+ for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
513
+ const batch = players.slice(i, i + MAX_CONCURRENT_OPS)
514
+ const batchResults = await Promise.allSettled(
515
+ batch.map((p) => this._migratePlayer(p, pickNode))
516
+ )
517
+ for (const r of batchResults)
518
+ results.push({ success: r.status === 'fulfilled', error: r.reason })
519
+ }
520
+ return results
521
+ }
522
+
523
+ async _migratePlayer(player, pickNode) {
524
+ const state = this._capturePlayerState(player)
525
+ if (!state) throw new Error('Failed to capture state')
526
+ const { maxRetries, retryDelay } = this.failoverOptions
527
+ for (let retry = 0; retry < maxRetries; retry++) {
528
+ try {
529
+ const targetNode = pickNode()
530
+ const newPlayer = this._createPlayerOnNode(targetNode, state)
531
+ await this._restorePlayerState(newPlayer, state)
532
+ this.emit(AqualinkEvents.PlayerMigrated, player, newPlayer, targetNode)
533
+ return newPlayer
534
+ } catch (error) {
535
+ if (retry === maxRetries - 1) throw error
536
+ await _functions.delay(retryDelay * 1.5 ** retry)
537
+ }
538
+ }
539
+ }
540
+
541
+ _capturePlayerState(player) {
542
+ if (!player) return null
543
+ let position = player.position || 0
544
+ if (player.playing && !player.paused && player.timestamp) {
545
+ const elapsed = Date.now() - player.timestamp
546
+ position = Math.min(
547
+ position + elapsed,
548
+ player.current?.info?.length || position + elapsed
549
+ )
550
+ }
551
+ return {
552
+ guildId: player.guildId,
553
+ textChannel: player.textChannel,
554
+ voiceChannel: player.voiceChannel,
555
+ volume: player.volume ?? 100,
556
+ paused: !!player.paused,
557
+ position,
558
+ current: player.current || null,
559
+ queue: player.queue?.toArray?.() || EMPTY_ARRAY,
560
+ loop: player.loop,
561
+ shuffle: player.shuffle,
562
+ deaf: player.deaf ?? false,
563
+ connected: !!player.connected
564
+ }
565
+ }
566
+
567
+ _createPlayerOnNode(targetNode, state) {
568
+ return this.createPlayer(targetNode, {
569
+ guildId: state.guildId,
570
+ textChannel: state.textChannel,
571
+ voiceChannel: state.voiceChannel,
572
+ defaultVolume: state.volume || 100,
573
+ deaf: state.deaf || false
574
+ })
575
+ }
576
+
577
+ async _restorePlayerState(newPlayer, state) {
578
+ const ops = []
579
+ if (typeof state.volume === 'number') {
580
+ if (typeof newPlayer.setVolume === 'function')
581
+ ops.push(newPlayer.setVolume(state.volume))
582
+ else newPlayer.volume = state.volume
583
+ }
584
+ if (state.queue?.length && newPlayer.queue?.add)
585
+ newPlayer.queue.add(...state.queue)
586
+ if (state.current && this.failoverOptions.preservePosition) {
587
+ newPlayer.queue?.add?.(state.current, { toFront: true })
588
+ if (this.failoverOptions.resumePlayback) {
589
+ ops.push(newPlayer.play())
590
+ if (state.position > 0) {
591
+ const guildId = newPlayer.guildId
592
+ const seekOnce = (p) => {
593
+ if (p.guildId === guildId) {
594
+ this.off(AqualinkEvents.TrackStart, seekOnce)
595
+ _functions.unrefTimeout(
596
+ () => newPlayer.seek?.(state.position),
597
+ 50
598
+ )
599
+ }
600
+ }
601
+ this.once(AqualinkEvents.TrackStart, seekOnce)
602
+ newPlayer.once('destroy', () =>
603
+ this.off(AqualinkEvents.TrackStart, seekOnce)
604
+ )
605
+ }
606
+ if (state.paused) ops.push(newPlayer.pause(true))
607
+ }
608
+ }
609
+ newPlayer.loop = state.loop
610
+ newPlayer.shuffle = state.shuffle
611
+ await Promise.allSettled(ops)
612
+ }
613
+
614
+ updateVoiceState({ d, t }) {
615
+ if (
616
+ !d?.guild_id ||
617
+ (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')
618
+ )
619
+ return
620
+ const player = this.players.get(String(d.guild_id))
621
+ if (!player) return
622
+
623
+ d.txId = player.txId
624
+ if (t === 'VOICE_STATE_UPDATE') {
625
+ if (d.user_id !== this.clientId) return
626
+ if (player.connection) {
627
+ if (!d.channel_id && player.connection.voiceChannel) {
628
+ player.connection.setStateUpdate(d)
629
+ } else {
630
+ player.connection.sessionId = d.session_id
631
+ player.connection.setStateUpdate(d)
632
+ }
633
+ }
634
+ } else {
635
+ player.connection?.setServerUpdate(d)
636
+ }
637
+ }
638
+
639
+ fetchRegion(region) {
640
+ if (!region) return this.leastUsedNodes
641
+ const lower = region.toLowerCase()
642
+ const filtered = []
643
+ for (const n of this.nodeMap.values()) {
644
+ if (n.connected && n.regions?.includes(lower)) filtered.push(n)
645
+ }
646
+ return Object.freeze(
647
+ filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b))
648
+ )
649
+ }
650
+
651
+ createConnection(options) {
652
+ if (!this.initiated) throw new Error('Aqua not initialized')
653
+ const existing = this.players.get(String(options.guildId))
654
+ if (existing && !existing.destroyed) {
655
+ if (
656
+ options.voiceChannel &&
657
+ existing.voiceChannel !== options.voiceChannel
658
+ ) {
659
+ _functions.safeCall(() => existing.connect(options))
660
+ }
661
+ return existing
662
+ }
663
+ const candidates = options.region
664
+ ? this.fetchRegion(options.region)
665
+ : this.leastUsedNodes
666
+ if (!candidates.length) throw new Error('No nodes available')
667
+ return this.createPlayer(this._chooseLeastBusyNode(candidates), options)
668
+ }
669
+
670
+ createPlayer(node, options) {
671
+ const existing = this.players.get(options.guildId)
672
+ if (existing) {
673
+ _functions.safeCall(() =>
674
+ existing.destroy({
675
+ preserveMessage:
676
+ options.preserveMessage || !!options.resuming || false,
677
+ preserveTracks: !!options.resuming || false
678
+ })
679
+ )
680
+ }
681
+ const player = new Player(this, node, options)
682
+ const guildId = String(options.guildId)
683
+ this.players.set(guildId, player)
684
+ node?.players?.add?.(player)
685
+ player.once('destroy', () => this._handlePlayerDestroy(player))
686
+ player.connect(options)
687
+ this.emit(AqualinkEvents.PlayerCreate, player)
688
+ return player
689
+ }
690
+
691
+ _handlePlayerDestroy(player) {
692
+ player.nodes?.players?.delete?.(player)
693
+ const guildId = String(player.guildId)
694
+ if (this.players.get(guildId) === player) this.players.delete(guildId)
695
+ this.emit(AqualinkEvents.PlayerDestroyed, player)
696
+ }
697
+
698
+ async destroyPlayer(guildId) {
699
+ const id = String(guildId)
700
+ const player = this.players.get(id)
701
+ if (!player) return
702
+ this.players.delete(id)
703
+ _functions.safeCall(() => player.removeAllListeners())
704
+ await _functions.safeCall(() => player.destroy())
705
+ }
706
+
707
+ async resolve({ query, source, requester, nodes }) {
708
+ if (!this.initiated) throw new Error('Aqua not initialized')
709
+ const node = this._getRequestNode(nodes)
710
+ if (!node) throw new Error('No nodes available')
711
+ const formatted = _functions.formatQuery(
712
+ query,
713
+ source || this.defaultSearchPlatform
714
+ )
715
+ const endpoint = `/${this.restVersion}/loadtracks?identifier=${encodeURIComponent(formatted)}`
716
+ try {
717
+ const response = await node.rest.makeRequest('GET', endpoint)
718
+ if (
719
+ !response ||
720
+ response.loadType === 'empty' ||
721
+ response.loadType === 'NO_MATCHES'
722
+ )
723
+ return EMPTY_TRACKS_RESPONSE
724
+ return this._constructResponse(response, requester, node)
725
+ } catch (error) {
726
+ throw new Error(
727
+ error?.name === 'AbortError'
728
+ ? 'Request timeout'
729
+ : `Resolve failed: ${error?.message || error}`
730
+ )
731
+ }
732
+ }
733
+
734
+ _getRequestNode(nodes) {
735
+ if (!nodes) return this._chooseLeastBusyNode(this.leastUsedNodes)
736
+ if (nodes instanceof Node) return nodes
737
+ if (Array.isArray(nodes)) {
738
+ const candidates = nodes.filter((n) => n?.connected)
739
+ return this._chooseLeastBusyNode(
740
+ candidates.length ? candidates : this.leastUsedNodes
741
+ )
742
+ }
743
+ if (typeof nodes === 'string') {
744
+ const node = this.nodeMap.get(nodes)
745
+ return node?.connected
746
+ ? node
747
+ : this._chooseLeastBusyNode(this.leastUsedNodes)
748
+ }
749
+ throw new TypeError(`Invalid nodes: ${typeof nodes}`)
750
+ }
751
+
752
+ _chooseLeastBusyNode(nodes) {
753
+ if (!nodes?.length) return null
754
+ if (nodes.length === 1) return nodes[0]
755
+ let best = nodes[0],
756
+ bestScore = this._getNodeLoad(best)
757
+ for (let i = 1; i < nodes.length; i++) {
758
+ const score = this._getNodeLoad(nodes[i])
759
+ if (score < bestScore) {
760
+ best = nodes[i]
761
+ bestScore = score
762
+ }
763
+ }
764
+ return best
765
+ }
766
+
767
+ _constructResponse(response, requester, node) {
768
+ const { loadType, data, pluginInfo: rootPlugin } = response || {}
769
+ const base = {
770
+ loadType,
771
+ exception: null,
772
+ playlistInfo: null,
773
+ pluginInfo: rootPlugin || {},
774
+ tracks: []
775
+ }
776
+ if (loadType === 'error' || loadType === 'LOAD_FAILED') {
777
+ base.exception = data || response.exception || null
778
+ return base
779
+ }
780
+ if (loadType === 'track' && data) {
781
+ base.pluginInfo =
782
+ data.info?.pluginInfo || data.pluginInfo || base.pluginInfo
783
+ base.tracks.push(_functions.makeTrack(data, requester, node))
784
+ } else if (loadType === 'playlist' && data) {
785
+ const info = data.info
786
+ if (info) {
787
+ base.playlistInfo = {
788
+ name: info.name || info.title,
789
+ thumbnail:
790
+ data.pluginInfo?.artworkUrl ||
791
+ data.tracks?.[0]?.info?.artworkUrl ||
792
+ null,
793
+ ...info
794
+ }
795
+ }
796
+ base.pluginInfo = data.pluginInfo || rootPlugin || base.pluginInfo
797
+ base.tracks = Array.isArray(data.tracks)
798
+ ? data.tracks.map((t) => _functions.makeTrack(t, requester, node))
799
+ : []
800
+ } else if (loadType === 'search') {
801
+ base.tracks = Array.isArray(data)
802
+ ? data.map((t) => _functions.makeTrack(t, requester, node))
803
+ : []
804
+ }
805
+ return base
806
+ }
807
+
808
+ get(guildId) {
809
+ const player = this.players.get(String(guildId))
810
+ if (!player) throw new Error(`Player not found: ${guildId}`)
811
+ return player
812
+ }
813
+
814
+ async search(query, requester, source) {
815
+ if (!query || !requester) return null
816
+ try {
817
+ const { tracks } = await this.resolve({
818
+ query,
819
+ source: source || this.defaultSearchPlatform,
820
+ requester
821
+ })
822
+ return tracks || null
823
+ } catch {
824
+ return null
825
+ }
826
+ }
827
+
828
+ async savePlayer(filePath = './AquaPlayers.jsonl') {
829
+ const lockFile = `${filePath}.lock`
830
+ const tempFile = `${filePath}.tmp`
831
+ let ws = null
832
+ try {
833
+ await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
834
+ ws = fs.createWriteStream(tempFile, { encoding: 'utf8', flags: 'w' })
835
+ const buffer = []
836
+ let drainPromise = Promise.resolve()
837
+
838
+ const nodeSessions = {}
839
+ for (const node of this.nodeMap.values()) {
840
+ if (node.sessionId) nodeSessions[node.name] = node.sessionId
841
+ }
842
+ buffer.push(JSON.stringify({ type: 'node_sessions', data: nodeSessions }))
843
+ for (const player of this.players.values()) {
844
+ const requester = player.requester || player.current?.requester
845
+ const data = {
846
+ g: player.guildId,
847
+ t: player.textChannel,
848
+ v: player.voiceChannel,
849
+ u: player.current?.uri || null,
850
+ p: player.position || 0,
851
+ ts: player.timestamp || 0,
852
+ q: player.queue
853
+ .toArray()
854
+ .slice(0, this.maxQueueSave)
855
+ .map((tr) => tr.uri),
856
+ r: requester ? `${requester.id}:${requester.username}` : null,
857
+ vol: player.volume,
858
+ pa: player.paused,
859
+ pl: player.playing,
860
+ nw: player.nowPlayingMessage?.id || null,
861
+ resuming: true
862
+ }
863
+ buffer.push(JSON.stringify(data))
864
+
865
+ if (buffer.length >= WRITE_BUFFER_SIZE) {
866
+ const chunk = `${buffer.join('\n')}\n`
867
+ buffer.length = 0
868
+ if (!ws.write(chunk)) {
869
+ drainPromise = drainPromise.then(
870
+ () => new Promise((r) => ws.once('drain', r))
871
+ )
872
+ }
873
+ }
874
+ }
875
+
876
+ if (buffer.length) ws.write(`${buffer.join('\n')}\n`)
877
+ await drainPromise
878
+ await new Promise((resolve, reject) =>
879
+ ws.end((err) => (err ? reject(err) : resolve()))
880
+ )
881
+ ws = null
882
+ await fs.promises.rename(tempFile, filePath)
883
+ } catch (error) {
884
+ console.error(`[Aqua/Autoresume]Error saving players:`, error)
885
+ this.emit(AqualinkEvents.Error, null, error)
886
+ if (ws) _functions.safeCall(() => ws.destroy())
887
+ await fs.promises.unlink(tempFile).catch(_functions.noop)
888
+ } finally {
889
+ if (ws) _functions.safeCall(() => ws.destroy())
890
+ await fs.promises.unlink(lockFile).catch(_functions.noop)
891
+ }
892
+ }
893
+
894
+ async loadPlayers(filePath = './AquaPlayers.jsonl') {
895
+ if (this._loading) return
896
+ this._loading = true
897
+ const lockFile = `${filePath}.lock`
898
+ let stream = null,
899
+ rl = null
900
+ try {
901
+ await fs.promises.access(filePath)
902
+ await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
903
+ await this._waitForFirstNode()
904
+
905
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' })
906
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
907
+
908
+ const batch = []
909
+ for await (const line of rl) {
910
+ if (!line.trim()) continue
911
+ try {
912
+ const parsed = JSON.parse(line)
913
+ if (parsed.type === 'node_sessions') continue
914
+ batch.push(parsed)
915
+ } catch {
916
+ continue
917
+ }
918
+ if (batch.length >= PLAYER_BATCH_SIZE) {
919
+ await Promise.allSettled(batch.map((p) => this._restorePlayer(p)))
920
+ batch.length = 0
921
+ }
922
+ }
923
+ if (batch.length)
924
+ await Promise.allSettled(batch.map((p) => this._restorePlayer(p)))
925
+ await fs.promises.writeFile(filePath, '')
926
+ } catch (err) {
927
+ if (err.code !== 'ENOENT') {
928
+ console.error(`[Aqua/Autoresume]Error loading players:`, err)
929
+ this.emit(AqualinkEvents.Error, null, err)
930
+ }
931
+ } finally {
932
+ this._loading = false
933
+ if (rl) _functions.safeCall(() => rl.close())
934
+ if (stream) _functions.safeCall(() => stream.destroy())
935
+ await fs.promises.unlink(lockFile).catch(_functions.noop)
936
+ }
937
+ }
938
+
939
+ async _restorePlayer(p) {
940
+ try {
941
+ const gId = String(p.g)
942
+ const existing = this.players.get(gId)
943
+ if (existing?.playing) return
944
+
945
+ const player =
946
+ existing ||
947
+ this.createPlayer(this._chooseLeastBusyNode(this.leastUsedNodes), {
948
+ guildId: gId,
949
+ textChannel: p.t,
950
+ voiceChannel: p.v,
951
+ defaultVolume: p.vol || 65,
952
+ deaf: true,
953
+ resuming: !!p.resuming
954
+ })
955
+ player._resuming = !!p.resuming
956
+ const requester = _functions.parseRequester(p.r)
957
+ const tracksToResolve = [p.u, ...(p.q || [])]
958
+ .filter(Boolean)
959
+ .slice(0, this.maxTracksRestore)
960
+ const resolved = await Promise.all(
961
+ tracksToResolve.map((uri) =>
962
+ this.resolve({ query: uri, requester }).catch(() => null)
963
+ )
964
+ )
965
+ const validTracks = resolved.flatMap((r) => r?.tracks || [])
966
+ if (validTracks.length && player.queue?.add) {
967
+ player.queue.add(...validTracks)
968
+ }
969
+ if (p.u && validTracks[0]) {
970
+ if (p.vol != null) {
971
+ if (typeof player.setVolume === 'function')
972
+ await player.setVolume(p.vol)
973
+ else player.volume = p.vol
974
+ }
975
+
976
+ if (p.p > 0) {
977
+ const seekOnce = (pl) => {
978
+ if (pl.guildId === gId) {
979
+ this.off(AqualinkEvents.TrackStart, seekOnce)
980
+ _functions.unrefTimeout(() => player.seek?.(p.p), 100)
981
+ }
982
+ }
983
+ this.on(AqualinkEvents.TrackStart, seekOnce)
984
+ player.once('destroy', () => this.off(AqualinkEvents.TrackStart, seekOnce))
985
+ }
986
+
987
+ await player.play(undefined, { startTime: p.p, paused: p.pa })
988
+ }
989
+ if (p.nw && p.t) {
990
+ const channel = this.client.channels?.cache?.get(p.t)
991
+ if (channel?.messages)
992
+ player.nowPlayingMessage = await channel.messages
993
+ .fetch(p.nw)
994
+ .catch(() => null)
995
+ }
996
+ } catch (e) {
997
+ console.error(
998
+ `[Aqua/Autoresume]Failed to restore player for guild: ${p.g}`,
999
+ e
1000
+ )
1001
+ }
1002
+ }
1003
+
1004
+ async _waitForFirstNode(timeout = NODE_TIMEOUT) {
1005
+ if (this.leastUsedNodes.length) return
1006
+ return new Promise((resolve, reject) => {
1007
+ let resolved = false
1008
+ const cleanup = () => {
1009
+ if (resolved) return
1010
+ resolved = true
1011
+ clearTimeout(timer)
1012
+ this.off(AqualinkEvents.NodeConnect, onReady)
1013
+ this.off(AqualinkEvents.NodeCreate, onReady)
1014
+ }
1015
+ const onReady = () => {
1016
+ if (this.leastUsedNodes.length) {
1017
+ cleanup()
1018
+ resolve()
1019
+ }
1020
+ }
1021
+ const timer = setTimeout(() => {
1022
+ cleanup()
1023
+ reject(new Error('Timeout waiting for first node'))
1024
+ }, timeout)
1025
+ timer.unref?.()
1026
+ this.on(AqualinkEvents.NodeConnect, onReady)
1027
+ this.on(AqualinkEvents.NodeCreate, onReady)
1028
+ onReady()
1029
+ })
1030
+ }
1031
+
1032
+ _performCleanup() {
1033
+ const now = Date.now()
1034
+ for (const [guildId, state] of this._brokenPlayers) {
1035
+ if (now - state.brokenAt > BROKEN_PLAYER_TTL)
1036
+ this._brokenPlayers.delete(guildId)
1037
+ }
1038
+ for (const [id, ts] of this._lastFailoverAttempt) {
1039
+ if (now - ts > FAILOVER_CLEANUP_TTL) {
1040
+ this._lastFailoverAttempt.delete(id)
1041
+ this._failoverQueue.delete(id)
1042
+ }
1043
+ }
1044
+ if (this._failoverQueue.size > MAX_FAILOVER_QUEUE)
1045
+ this._failoverQueue.clear()
1046
+ if (this._rebuildLocks.size > MAX_REBUILD_LOCKS) this._rebuildLocks.clear()
1047
+ for (const id of this._nodeStates.keys()) {
1048
+ if (!this.nodeMap.has(id)) this._nodeStates.delete(id)
1049
+ }
1050
+ }
1051
+
1052
+ async _loadNodeSessions(filePath = './AquaPlayers.jsonl') {
1053
+ let stream = null,
1054
+ rl = null
1055
+ try {
1056
+ await fs.promises.access(filePath)
1057
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' })
1058
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
1059
+
1060
+ for await (const line of rl) {
1061
+ if (!line.trim()) continue
1062
+ try {
1063
+ const parsed = JSON.parse(line)
1064
+ if (parsed.type === 'node_sessions') {
1065
+ for (const [name, sessionId] of Object.entries(parsed.data)) {
1066
+ const nodeOptions = this.nodes.find(
1067
+ (n) => (n.name || n.host) === name
1068
+ )
1069
+ if (nodeOptions) {
1070
+ nodeOptions.sessionId = sessionId
1071
+ }
1072
+ }
1073
+ break
1074
+ }
1075
+ } catch {
1076
+ }
1077
+ }
1078
+ } catch {
1079
+ } finally {
1080
+ if (rl) _functions.safeCall(() => rl.close())
1081
+ if (stream) _functions.safeCall(() => stream.destroy())
1082
+ }
1083
+ }
1084
+ }
1085
+
897
1086
  module.exports = Aqua