aqualink 2.14.0 → 2.15.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.
- package/build/structures/Aqua.js +172 -121
- package/build/structures/Connection.js +44 -5
- package/build/structures/Node.js +96 -149
- package/build/structures/Player.js +325 -240
- package/build/structures/Queue.js +61 -80
- package/build/structures/Rest.js +87 -55
- package/build/structures/Track.js +83 -80
- package/package.json +1 -1
package/build/structures/Aqua.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs')
|
|
4
4
|
const readline = require('node:readline')
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
5
|
+
const {EventEmitter} = require('tseep')
|
|
6
|
+
const {AqualinkEvents} = require('./AqualinkEvents')
|
|
7
7
|
const Node = require('./Node')
|
|
8
8
|
const Player = require('./Player')
|
|
9
9
|
const Track = require('./Track')
|
|
10
|
-
const {
|
|
10
|
+
const {version: pkgVersion} = require('../../package.json')
|
|
11
11
|
|
|
12
|
+
// Constants
|
|
12
13
|
const SEARCH_PREFIX = ':'
|
|
13
14
|
const EMPTY_ARRAY = Object.freeze([])
|
|
14
15
|
const EMPTY_TRACKS_RESPONSE = Object.freeze({
|
|
@@ -30,6 +31,10 @@ const NODE_TIMEOUT = 30000
|
|
|
30
31
|
const MAX_CACHE_SIZE = 20
|
|
31
32
|
const MAX_BROKEN_PLAYERS = 50
|
|
32
33
|
const MAX_FAILOVER_QUEUE = 50
|
|
34
|
+
const MAX_REBUILD_LOCKS = 100
|
|
35
|
+
const WRITE_BUFFER_SIZE = 100
|
|
36
|
+
const MAX_QUEUE_SAVE = 10
|
|
37
|
+
const MAX_TRACKS_RESTORE = 20
|
|
33
38
|
const URL_PATTERN = /^https?:\/\//i
|
|
34
39
|
|
|
35
40
|
const DEFAULT_OPTIONS = Object.freeze({
|
|
@@ -53,13 +58,18 @@ const DEFAULT_OPTIONS = Object.freeze({
|
|
|
53
58
|
})
|
|
54
59
|
})
|
|
55
60
|
|
|
61
|
+
// Utility functions (moved to module scope for reuse)
|
|
56
62
|
const _delay = ms => new Promise(r => setTimeout(r, ms))
|
|
57
|
-
const _noop = () => {
|
|
63
|
+
const _noop = () => {}
|
|
58
64
|
const _isUrl = query => typeof query === 'string' && query.length > 8 && URL_PATTERN.test(query)
|
|
59
65
|
const _formatQuery = (query, source) => _isUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}`
|
|
60
66
|
const _makeTrack = (t, requester, node) => new Track(t, requester, node)
|
|
61
|
-
const _safeCall = fn => {
|
|
62
|
-
|
|
67
|
+
const _safeCall = fn => {
|
|
68
|
+
try {
|
|
69
|
+
const result = fn()
|
|
70
|
+
return result?.then ? result.catch(_noop) : result
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
63
73
|
|
|
64
74
|
class Aqua extends EventEmitter {
|
|
65
75
|
constructor(client, nodes, options = {}) {
|
|
@@ -75,9 +85,9 @@ class Aqua extends EventEmitter {
|
|
|
75
85
|
this.initiated = false
|
|
76
86
|
this.version = pkgVersion
|
|
77
87
|
|
|
78
|
-
const merged = {
|
|
88
|
+
const merged = {...DEFAULT_OPTIONS, ...options}
|
|
79
89
|
this.options = merged
|
|
80
|
-
this.failoverOptions = {
|
|
90
|
+
this.failoverOptions = {...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions}
|
|
81
91
|
|
|
82
92
|
this.shouldDeleteMessage = merged.shouldDeleteMessage
|
|
83
93
|
this.defaultSearchPlatform = merged.defaultSearchPlatform
|
|
@@ -101,8 +111,9 @@ class Aqua extends EventEmitter {
|
|
|
101
111
|
this._leastUsedNodesCache = null
|
|
102
112
|
this._leastUsedNodesCacheTime = 0
|
|
103
113
|
this._nodeLoadCache = new Map()
|
|
114
|
+
this._eventHandlers = null
|
|
104
115
|
|
|
105
|
-
this._bindEventHandlers()
|
|
116
|
+
if (this.autoResume) this._bindEventHandlers()
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
_createDefaultSend() {
|
|
@@ -118,30 +129,46 @@ class Aqua extends EventEmitter {
|
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
_bindEventHandlers() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
const onNodeDisconnect = node => {
|
|
128
|
-
this._invalidateCache()
|
|
129
|
-
queueMicrotask(() => {
|
|
130
|
-
this._storeBrokenPlayers(node)
|
|
132
|
+
// Store handlers for cleanup
|
|
133
|
+
this._eventHandlers = {
|
|
134
|
+
onNodeConnect: async node => {
|
|
135
|
+
this._invalidateCache()
|
|
136
|
+
await this._rebuildBrokenPlayers(node)
|
|
131
137
|
this._performCleanup()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
},
|
|
139
|
+
onNodeDisconnect: node => {
|
|
140
|
+
this._invalidateCache()
|
|
141
|
+
queueMicrotask(() => {
|
|
142
|
+
this._storeBrokenPlayers(node)
|
|
143
|
+
this._performCleanup()
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
onNodeReady: (node, {resumed}) => {
|
|
147
|
+
if (!resumed) return
|
|
148
|
+
const batch = []
|
|
149
|
+
for (const player of this.players.values()) {
|
|
150
|
+
if (player.nodes === node && player.connection) batch.push(player)
|
|
151
|
+
}
|
|
152
|
+
if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate({resume: true})))
|
|
139
153
|
}
|
|
140
|
-
if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate({ resume: true })))
|
|
141
154
|
}
|
|
142
|
-
this.on(AqualinkEvents.NodeConnect, onNodeConnect)
|
|
143
|
-
this.on(AqualinkEvents.NodeDisconnect, onNodeDisconnect)
|
|
144
|
-
this.on(AqualinkEvents.NodeReady, onNodeReady)
|
|
155
|
+
this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
|
|
156
|
+
this.on(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
|
|
157
|
+
this.on(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Cleanup event handlers to prevent memory leaks
|
|
161
|
+
destroy() {
|
|
162
|
+
if (this._eventHandlers) {
|
|
163
|
+
this.off(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
|
|
164
|
+
this.off(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect)
|
|
165
|
+
this.off(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady)
|
|
166
|
+
this._eventHandlers = null
|
|
167
|
+
}
|
|
168
|
+
this.removeAllListeners()
|
|
169
|
+
this.nodeMap.forEach(node => this._destroyNode(node.name || node.host))
|
|
170
|
+
this.players.forEach(player => _safeCall(() => player.destroy()))
|
|
171
|
+
this.players.clear()
|
|
145
172
|
}
|
|
146
173
|
|
|
147
174
|
get leastUsedNodes() {
|
|
@@ -149,10 +176,7 @@ class Aqua extends EventEmitter {
|
|
|
149
176
|
if (this._leastUsedNodesCache && (now - this._leastUsedNodesCacheTime) < CACHE_VALID_TIME) {
|
|
150
177
|
return this._leastUsedNodesCache
|
|
151
178
|
}
|
|
152
|
-
const connected =
|
|
153
|
-
for (const node of this.nodeMap.values()) {
|
|
154
|
-
if (node.connected) connected.push(node)
|
|
155
|
-
}
|
|
179
|
+
const connected = Array.from(this.nodeMap.values()).filter(n => n.connected)
|
|
156
180
|
const sorted = this.loadBalancer === 'leastRest'
|
|
157
181
|
? connected.sort((a, b) => (a.rest?.calls || 0) - (b.rest?.calls || 0))
|
|
158
182
|
: this.loadBalancer === 'random'
|
|
@@ -179,7 +203,7 @@ class Aqua extends EventEmitter {
|
|
|
179
203
|
(stats.playingPlayers || 0) * 0.75 +
|
|
180
204
|
(stats.memory ? stats.memory.used / Math.max(1, stats.memory.reservable) : 0) * 40 +
|
|
181
205
|
(node.rest?.calls || 0) * 0.001
|
|
182
|
-
this._nodeLoadCache.set(id, {
|
|
206
|
+
this._nodeLoadCache.set(id, {load, time: now})
|
|
183
207
|
if (this._nodeLoadCache.size > MAX_CACHE_SIZE) {
|
|
184
208
|
const first = this._nodeLoadCache.keys().next().value
|
|
185
209
|
this._nodeLoadCache.delete(first)
|
|
@@ -192,14 +216,11 @@ class Aqua extends EventEmitter {
|
|
|
192
216
|
this.clientId = clientId
|
|
193
217
|
if (!this.clientId) return
|
|
194
218
|
const results = await Promise.allSettled(
|
|
195
|
-
this.nodes.map(n => Promise.race([this._createNode(n), _delay(NODE_TIMEOUT).then(() => {
|
|
219
|
+
this.nodes.map(n => Promise.race([this._createNode(n), _delay(NODE_TIMEOUT).then(() => {throw new Error('Timeout')})]))
|
|
196
220
|
)
|
|
197
221
|
if (!results.some(r => r.status === 'fulfilled')) throw new Error('No nodes connected')
|
|
198
222
|
if (this.plugins?.length) {
|
|
199
|
-
await Promise.allSettled(this.plugins.map(p =>
|
|
200
|
-
try { return p.load(this) }
|
|
201
|
-
catch (err) { this.emit(AqualinkEvents.Error, null, err) }
|
|
202
|
-
}))
|
|
223
|
+
await Promise.allSettled(this.plugins.map(p => _safeCall(() => p.load(this))))
|
|
203
224
|
}
|
|
204
225
|
this.initiated = true
|
|
205
226
|
return this
|
|
@@ -211,10 +232,10 @@ class Aqua extends EventEmitter {
|
|
|
211
232
|
const node = new Node(this, options, this.options)
|
|
212
233
|
node.players = new Set()
|
|
213
234
|
this.nodeMap.set(id, node)
|
|
214
|
-
this._nodeStates.set(id, {
|
|
235
|
+
this._nodeStates.set(id, {connected: false, failoverInProgress: false})
|
|
215
236
|
try {
|
|
216
237
|
await node.connect()
|
|
217
|
-
this._nodeStates.set(id, {
|
|
238
|
+
this._nodeStates.set(id, {connected: true, failoverInProgress: false})
|
|
218
239
|
this._invalidateCache()
|
|
219
240
|
this.emit(AqualinkEvents.NodeCreate, node)
|
|
220
241
|
return node
|
|
@@ -267,7 +288,7 @@ class Aqua extends EventEmitter {
|
|
|
267
288
|
const now = Date.now()
|
|
268
289
|
for (const [guildId, state] of this._brokenPlayers) {
|
|
269
290
|
if (state.originalNodeId === id && (now - state.brokenAt) < BROKEN_PLAYER_TTL) {
|
|
270
|
-
rebuilds.push({
|
|
291
|
+
rebuilds.push({guildId, state})
|
|
271
292
|
}
|
|
272
293
|
}
|
|
273
294
|
if (!rebuilds.length) return
|
|
@@ -275,7 +296,7 @@ class Aqua extends EventEmitter {
|
|
|
275
296
|
for (let i = 0; i < rebuilds.length; i += MAX_CONCURRENT_OPS) {
|
|
276
297
|
const batch = rebuilds.slice(i, i + MAX_CONCURRENT_OPS)
|
|
277
298
|
const results = await Promise.allSettled(
|
|
278
|
-
batch.map(({
|
|
299
|
+
batch.map(({guildId, state}) => this._rebuildPlayer(state, node).then(() => guildId))
|
|
279
300
|
)
|
|
280
301
|
results.forEach(r => {
|
|
281
302
|
if (r.status === 'fulfilled') successes.push(r.value)
|
|
@@ -287,7 +308,7 @@ class Aqua extends EventEmitter {
|
|
|
287
308
|
}
|
|
288
309
|
|
|
289
310
|
async _rebuildPlayer(state, targetNode) {
|
|
290
|
-
const {
|
|
311
|
+
const {guildId, textChannel, voiceChannel, current, volume = 65, deaf = true} = state
|
|
291
312
|
const lockKey = `rebuild_${guildId}`
|
|
292
313
|
if (this._rebuildLocks.has(lockKey)) return
|
|
293
314
|
this._rebuildLocks.add(lockKey)
|
|
@@ -297,7 +318,7 @@ class Aqua extends EventEmitter {
|
|
|
297
318
|
await this.destroyPlayer(guildId)
|
|
298
319
|
await _delay(RECONNECT_DELAY)
|
|
299
320
|
}
|
|
300
|
-
const player = this.createPlayer(targetNode, {
|
|
321
|
+
const player = this.createPlayer(targetNode, {guildId, textChannel, voiceChannel, defaultVolume: volume, deaf})
|
|
301
322
|
if (current && player?.queue?.add) {
|
|
302
323
|
player.queue.add(current)
|
|
303
324
|
await player.play()
|
|
@@ -321,7 +342,7 @@ class Aqua extends EventEmitter {
|
|
|
321
342
|
const attempts = this._failoverQueue.get(id) || 0
|
|
322
343
|
if (attempts >= this.failoverOptions.maxFailoverAttempts) return
|
|
323
344
|
|
|
324
|
-
this._nodeStates.set(id, {
|
|
345
|
+
this._nodeStates.set(id, {connected: false, failoverInProgress: true})
|
|
325
346
|
this._lastFailoverAttempt.set(id, now)
|
|
326
347
|
this._failoverQueue.set(id, attempts + 1)
|
|
327
348
|
|
|
@@ -329,7 +350,7 @@ class Aqua extends EventEmitter {
|
|
|
329
350
|
this.emit(AqualinkEvents.NodeFailover, failedNode)
|
|
330
351
|
const players = Array.from(failedNode.players || [])
|
|
331
352
|
if (!players.length) return
|
|
332
|
-
const available = this.
|
|
353
|
+
const available = Array.from(this.nodeMap.values()).filter(n => n !== failedNode && n.connected)
|
|
333
354
|
if (!available.length) throw new Error('No failover nodes')
|
|
334
355
|
const results = await this._migratePlayersOptimized(players, available)
|
|
335
356
|
const successful = results.filter(r => r.success).length
|
|
@@ -340,7 +361,7 @@ class Aqua extends EventEmitter {
|
|
|
340
361
|
} catch (error) {
|
|
341
362
|
this.emit(AqualinkEvents.Error, null, error)
|
|
342
363
|
} finally {
|
|
343
|
-
this._nodeStates.set(id, {
|
|
364
|
+
this._nodeStates.set(id, {connected: false, failoverInProgress: false})
|
|
344
365
|
}
|
|
345
366
|
}
|
|
346
367
|
|
|
@@ -364,7 +385,7 @@ class Aqua extends EventEmitter {
|
|
|
364
385
|
for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
|
|
365
386
|
const batch = players.slice(i, i + MAX_CONCURRENT_OPS)
|
|
366
387
|
const batchResults = await Promise.allSettled(batch.map(p => this._migratePlayer(p, pickNode)))
|
|
367
|
-
results.push(...batchResults.map(r => ({
|
|
388
|
+
results.push(...batchResults.map(r => ({success: r.status === 'fulfilled', error: r.reason})))
|
|
368
389
|
}
|
|
369
390
|
return results
|
|
370
391
|
}
|
|
@@ -375,7 +396,7 @@ class Aqua extends EventEmitter {
|
|
|
375
396
|
for (let retry = 0; retry < this.failoverOptions.maxRetries; retry++) {
|
|
376
397
|
try {
|
|
377
398
|
const targetNode = pickNode()
|
|
378
|
-
const newPlayer =
|
|
399
|
+
const newPlayer = this._createPlayerOnNode(targetNode, state)
|
|
379
400
|
await this._restorePlayerState(newPlayer, state)
|
|
380
401
|
this.emit(AqualinkEvents.PlayerMigrated, player, newPlayer, targetNode)
|
|
381
402
|
return newPlayer
|
|
@@ -421,18 +442,18 @@ class Aqua extends EventEmitter {
|
|
|
421
442
|
}
|
|
422
443
|
if (state.queue?.length && newPlayer.queue?.add) newPlayer.queue.add(...state.queue)
|
|
423
444
|
if (state.current && this.failoverOptions.preservePosition) {
|
|
424
|
-
newPlayer.queue?.add?.(state.current, {
|
|
445
|
+
newPlayer.queue?.add?.(state.current, {toFront: true})
|
|
425
446
|
if (this.failoverOptions.resumePlayback) {
|
|
426
447
|
ops.push(newPlayer.play())
|
|
427
448
|
if (state.position > 0) setTimeout(() => newPlayer.seek?.(state.position), SEEK_DELAY)
|
|
428
449
|
if (state.paused) ops.push(newPlayer.pause(true))
|
|
429
450
|
}
|
|
430
451
|
}
|
|
431
|
-
Object.assign(newPlayer, {
|
|
452
|
+
Object.assign(newPlayer, {loop: state.loop, shuffle: state.shuffle})
|
|
432
453
|
await Promise.allSettled(ops)
|
|
433
454
|
}
|
|
434
455
|
|
|
435
|
-
updateVoiceState({
|
|
456
|
+
updateVoiceState({d, t}) {
|
|
436
457
|
if (!d?.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')) return
|
|
437
458
|
const player = this.players.get(d.guild_id)
|
|
438
459
|
if (!player) return
|
|
@@ -454,10 +475,7 @@ class Aqua extends EventEmitter {
|
|
|
454
475
|
fetchRegion(region) {
|
|
455
476
|
if (!region) return this.leastUsedNodes
|
|
456
477
|
const lower = region.toLowerCase()
|
|
457
|
-
const filtered =
|
|
458
|
-
for (const node of this.nodeMap.values()) {
|
|
459
|
-
if (node.connected && node.regions?.includes(lower)) filtered.push(node)
|
|
460
|
-
}
|
|
478
|
+
const filtered = Array.from(this.nodeMap.values()).filter(n => n.connected && n.regions?.includes(lower))
|
|
461
479
|
return Object.freeze(filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b)))
|
|
462
480
|
}
|
|
463
481
|
|
|
@@ -498,10 +516,10 @@ class Aqua extends EventEmitter {
|
|
|
498
516
|
if (!player) return
|
|
499
517
|
this.players.delete(guildId)
|
|
500
518
|
_safeCall(() => player.removeAllListeners())
|
|
501
|
-
await
|
|
519
|
+
await _safeCall(() => player.destroy())
|
|
502
520
|
}
|
|
503
521
|
|
|
504
|
-
async resolve({
|
|
522
|
+
async resolve({query, source = this.defaultSearchPlatform, requester, nodes}) {
|
|
505
523
|
if (!this.initiated) throw new Error('Aqua not initialized')
|
|
506
524
|
const node = this._getRequestNode(nodes)
|
|
507
525
|
if (!node) throw new Error('No nodes available')
|
|
@@ -541,8 +559,8 @@ class Aqua extends EventEmitter {
|
|
|
541
559
|
}
|
|
542
560
|
|
|
543
561
|
_constructResponse(response, requester, node) {
|
|
544
|
-
const {
|
|
545
|
-
const base = {
|
|
562
|
+
const {loadType, data, pluginInfo: rootPlugin} = response || {}
|
|
563
|
+
const base = {loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: []}
|
|
546
564
|
if (loadType === 'error' || loadType === 'LOAD_FAILED') {
|
|
547
565
|
base.exception = data || response.exception || null
|
|
548
566
|
return base
|
|
@@ -558,7 +576,7 @@ class Aqua extends EventEmitter {
|
|
|
558
576
|
if (data) {
|
|
559
577
|
const info = data.info || null
|
|
560
578
|
const thumbnail = data.pluginInfo?.artworkUrl || data.tracks?.[0]?.info?.artworkUrl || null
|
|
561
|
-
if (info) base.playlistInfo = {
|
|
579
|
+
if (info) base.playlistInfo = {name: info.name || info.title, thumbnail, ...info}
|
|
562
580
|
base.pluginInfo = data.pluginInfo || base.pluginInfo
|
|
563
581
|
base.tracks = Array.isArray(data.tracks) ? data.tracks.map(t => _makeTrack(t, requester, node)) : []
|
|
564
582
|
}
|
|
@@ -579,43 +597,26 @@ class Aqua extends EventEmitter {
|
|
|
579
597
|
async search(query, requester, source = this.defaultSearchPlatform) {
|
|
580
598
|
if (!query || !requester) return null
|
|
581
599
|
try {
|
|
582
|
-
const {
|
|
600
|
+
const {tracks} = await this.resolve({query, source: source || this.defaultSearchPlatform, requester})
|
|
583
601
|
return tracks || null
|
|
584
|
-
} catch {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
async loadPlayers(filePath = './AquaPlayers.jsonl') {
|
|
588
|
-
const lockFile = `${filePath}.lock`
|
|
589
|
-
try {
|
|
590
|
-
await fs.promises.access(filePath).catch(_noop)
|
|
591
|
-
await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' }).catch(_noop)
|
|
592
|
-
await this._waitForFirstNode()
|
|
593
|
-
const stream = fs.createReadStream(filePath, { encoding: 'utf8' })
|
|
594
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
|
|
595
|
-
const batch = []
|
|
596
|
-
for await (const line of rl) {
|
|
597
|
-
if (!line.trim()) continue
|
|
598
|
-
try { batch.push(JSON.parse(line)) } catch { continue }
|
|
599
|
-
if (batch.length >= PLAYER_BATCH_SIZE) {
|
|
600
|
-
await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
|
|
601
|
-
batch.length = 0
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
if (batch.length) await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
|
|
605
|
-
await fs.promises.writeFile(filePath, '')
|
|
606
|
-
} catch { } finally {
|
|
607
|
-
await fs.promises.unlink(lockFile).catch(_noop)
|
|
602
|
+
} catch {
|
|
603
|
+
return null
|
|
608
604
|
}
|
|
609
605
|
}
|
|
610
606
|
|
|
611
607
|
async savePlayer(filePath = './AquaPlayers.jsonl') {
|
|
612
608
|
const lockFile = `${filePath}.lock`
|
|
609
|
+
const tempFile = `${filePath}.tmp`
|
|
610
|
+
let ws = null
|
|
613
611
|
try {
|
|
614
|
-
await fs.promises.writeFile(lockFile, String(process.pid), {
|
|
615
|
-
|
|
612
|
+
await fs.promises.writeFile(lockFile, String(process.pid), {flag: 'wx'})
|
|
613
|
+
ws = fs.createWriteStream(tempFile, {encoding: 'utf8', flags: 'w'})
|
|
616
614
|
const buffer = []
|
|
615
|
+
let writePromise = Promise.resolve()
|
|
616
|
+
|
|
617
617
|
for (const player of this.players.values()) {
|
|
618
618
|
const requester = player.requester || player.current?.requester
|
|
619
|
+
console.log(`Saving player for guild ${player.guildId} to ${player}`)
|
|
619
620
|
const data = {
|
|
620
621
|
g: player.guildId,
|
|
621
622
|
t: player.textChannel,
|
|
@@ -623,28 +624,84 @@ class Aqua extends EventEmitter {
|
|
|
623
624
|
u: player.current?.uri || null,
|
|
624
625
|
p: player.position || 0,
|
|
625
626
|
ts: player.timestamp || 0,
|
|
626
|
-
q: player.queue.slice(0,
|
|
627
|
-
r: requester ?
|
|
627
|
+
q: player.queue.slice(0, MAX_QUEUE_SAVE).map(tr => tr.uri),
|
|
628
|
+
r: requester ? `${requester.id}:${requester.username}` : null,
|
|
628
629
|
vol: player.volume,
|
|
629
630
|
pa: player.paused,
|
|
630
631
|
pl: player.playing,
|
|
631
|
-
nw: player.nowPlayingMessage?.id || null
|
|
632
|
+
nw: player.nowPlayingMessage?.id || null,
|
|
633
|
+
resuming: true
|
|
632
634
|
}
|
|
633
635
|
buffer.push(JSON.stringify(data))
|
|
634
|
-
|
|
635
|
-
|
|
636
|
+
|
|
637
|
+
if (buffer.length >= WRITE_BUFFER_SIZE) {
|
|
638
|
+
const chunk = buffer.join('\n') + '\n'
|
|
636
639
|
buffer.length = 0
|
|
640
|
+
if (!ws.write(chunk)) {
|
|
641
|
+
writePromise = writePromise.then(() => new Promise(resolve => ws.once('drain', resolve)))
|
|
642
|
+
}
|
|
637
643
|
}
|
|
638
644
|
}
|
|
639
|
-
|
|
640
|
-
|
|
645
|
+
|
|
646
|
+
if (buffer.length) {
|
|
647
|
+
const chunk = buffer.join('\n') + '\n'
|
|
648
|
+
ws.write(chunk)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
await writePromise
|
|
652
|
+
await new Promise((resolve, reject) => {
|
|
653
|
+
ws.end(err => err ? reject(err) : resolve())
|
|
654
|
+
})
|
|
655
|
+
ws = null
|
|
656
|
+
|
|
657
|
+
await fs.promises.rename(tempFile, filePath)
|
|
641
658
|
} catch (error) {
|
|
642
659
|
this.emit(AqualinkEvents.Error, null, error)
|
|
660
|
+
if (ws) _safeCall(() => ws.destroy())
|
|
661
|
+
await fs.promises.unlink(tempFile).catch(_noop)
|
|
643
662
|
} finally {
|
|
644
663
|
await fs.promises.unlink(lockFile).catch(_noop)
|
|
645
664
|
}
|
|
646
665
|
}
|
|
647
666
|
|
|
667
|
+
async loadPlayers(filePath = './AquaPlayers.jsonl') {
|
|
668
|
+
const lockFile = `${filePath}.lock`
|
|
669
|
+
let stream = null
|
|
670
|
+
let rl = null
|
|
671
|
+
try {
|
|
672
|
+
await fs.promises.access(filePath)
|
|
673
|
+
await fs.promises.writeFile(lockFile, String(process.pid), {flag: 'wx'})
|
|
674
|
+
await this._waitForFirstNode()
|
|
675
|
+
|
|
676
|
+
stream = fs.createReadStream(filePath, {encoding: 'utf8'})
|
|
677
|
+
rl = readline.createInterface({input: stream, crlfDelay: Infinity})
|
|
678
|
+
|
|
679
|
+
const batch = []
|
|
680
|
+
for await (const line of rl) {
|
|
681
|
+
if (!line.trim()) continue
|
|
682
|
+
try {
|
|
683
|
+
batch.push(JSON.parse(line))
|
|
684
|
+
} catch {
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
687
|
+
if (batch.length >= PLAYER_BATCH_SIZE) {
|
|
688
|
+
await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
|
|
689
|
+
batch.length = 0
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (batch.length) await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
|
|
693
|
+
|
|
694
|
+
// Clear file after successful load
|
|
695
|
+
await fs.promises.writeFile(filePath, '')
|
|
696
|
+
} catch (err) {
|
|
697
|
+
if (err.code !== 'ENOENT') this.emit(AqualinkEvents.Error, null, err)
|
|
698
|
+
} finally {
|
|
699
|
+
if (rl) _safeCall(() => rl.close())
|
|
700
|
+
if (stream) _safeCall(() => stream.destroy())
|
|
701
|
+
await fs.promises.unlink(lockFile).catch(_noop)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
648
705
|
async _restorePlayer(p) {
|
|
649
706
|
try {
|
|
650
707
|
const player = this.players.get(p.g) || this.createPlayer(this._chooseLeastBusyNode(this.leastUsedNodes), {
|
|
@@ -652,14 +709,19 @@ class Aqua extends EventEmitter {
|
|
|
652
709
|
textChannel: p.t,
|
|
653
710
|
voiceChannel: p.v,
|
|
654
711
|
defaultVolume: p.vol || 65,
|
|
655
|
-
deaf: true
|
|
712
|
+
deaf: true,
|
|
713
|
+
// indicate this player is being restored from persisted state so Connection
|
|
714
|
+
// can behave accordingly (attempt resume even if voice data is stale)
|
|
715
|
+
resuming: !!p.resuming
|
|
656
716
|
})
|
|
717
|
+
// Ensure flag is set on existing players as well
|
|
718
|
+
player._resuming = !!p.resuming
|
|
657
719
|
const requester = this._parseRequester(p.r)
|
|
658
|
-
const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0,
|
|
659
|
-
const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({
|
|
720
|
+
const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0, MAX_TRACKS_RESTORE)
|
|
721
|
+
const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({query: uri, requester}).catch(() => null)))
|
|
660
722
|
const validTracks = resolved.flatMap(r => r?.tracks || [])
|
|
661
723
|
if (validTracks.length && player.queue?.add) {
|
|
662
|
-
if (player.queue.length <= 2) player.queue.length = 0
|
|
724
|
+
if (player.queue.length <= 2) player.queue.length = 0
|
|
663
725
|
player.queue.add(...validTracks)
|
|
664
726
|
}
|
|
665
727
|
if (p.u && validTracks[0]) {
|
|
@@ -677,16 +739,13 @@ class Aqua extends EventEmitter {
|
|
|
677
739
|
player.nowPlayingMessage = await channel.messages.fetch(p.nw).catch(() => null)
|
|
678
740
|
}
|
|
679
741
|
}
|
|
680
|
-
} catch {
|
|
742
|
+
} catch {}
|
|
681
743
|
}
|
|
682
744
|
|
|
683
745
|
_parseRequester(str) {
|
|
684
746
|
if (!str || typeof str !== 'string') return null
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const i = str.indexOf(':')
|
|
688
|
-
return i > 0 ? { id: str.substring(0, i), username: str.substring(i + 1) } : null
|
|
689
|
-
}
|
|
747
|
+
const i = str.indexOf(':')
|
|
748
|
+
return i > 0 ? {id: str.substring(0, i), username: str.substring(i + 1)} : null
|
|
690
749
|
}
|
|
691
750
|
|
|
692
751
|
async _waitForFirstNode(timeout = NODE_TIMEOUT) {
|
|
@@ -714,12 +773,12 @@ class Aqua extends EventEmitter {
|
|
|
714
773
|
_performCleanup() {
|
|
715
774
|
const now = Date.now()
|
|
716
775
|
|
|
776
|
+
// Cleanup expired broken players
|
|
717
777
|
for (const [guildId, state] of this._brokenPlayers) {
|
|
718
|
-
if (now - state.brokenAt > BROKEN_PLAYER_TTL)
|
|
719
|
-
this._brokenPlayers.delete(guildId)
|
|
720
|
-
}
|
|
778
|
+
if (now - state.brokenAt > BROKEN_PLAYER_TTL) this._brokenPlayers.delete(guildId)
|
|
721
779
|
}
|
|
722
780
|
|
|
781
|
+
// Cleanup old failover attempts
|
|
723
782
|
for (const [id, ts] of this._lastFailoverAttempt) {
|
|
724
783
|
if (now - ts > FAILOVER_CLEANUP_TTL) {
|
|
725
784
|
this._lastFailoverAttempt.delete(id)
|
|
@@ -729,27 +788,19 @@ class Aqua extends EventEmitter {
|
|
|
729
788
|
|
|
730
789
|
this._trimBrokenPlayers()
|
|
731
790
|
if (this._failoverQueue.size > MAX_FAILOVER_QUEUE) this._failoverQueue.clear()
|
|
732
|
-
if (this._rebuildLocks.size >
|
|
791
|
+
if (this._rebuildLocks.size > MAX_REBUILD_LOCKS) this._rebuildLocks.clear()
|
|
733
792
|
|
|
734
|
-
|
|
793
|
+
// Cleanup orphaned node states
|
|
794
|
+
for (const id of this._nodeStates.keys()) {
|
|
735
795
|
if (!this.nodeMap.has(id)) this._nodeStates.delete(id)
|
|
736
796
|
}
|
|
737
797
|
}
|
|
738
798
|
|
|
739
|
-
|
|
740
799
|
_trimBrokenPlayers() {
|
|
741
800
|
if (this._brokenPlayers.size <= MAX_BROKEN_PLAYERS) return
|
|
742
801
|
const sorted = [...this._brokenPlayers.entries()].sort((a, b) => a[1].brokenAt - b[1].brokenAt)
|
|
743
802
|
sorted.slice(0, sorted.length - MAX_BROKEN_PLAYERS).forEach(([id]) => this._brokenPlayers.delete(id))
|
|
744
803
|
}
|
|
745
|
-
|
|
746
|
-
_getAvailableNodes(excludeNode) {
|
|
747
|
-
const nodes = []
|
|
748
|
-
for (const node of this.nodeMap.values()) {
|
|
749
|
-
if (node !== excludeNode && node.connected) nodes.push(node)
|
|
750
|
-
}
|
|
751
|
-
return nodes
|
|
752
|
-
}
|
|
753
804
|
}
|
|
754
805
|
|
|
755
806
|
module.exports = Aqua
|
|
@@ -125,10 +125,18 @@ class Connection {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
_canAttemptResume () {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
// Allow resume if connection isn't destroyed, attempts remain, and we're
|
|
129
|
+
// not already attempting or disconnecting. If voice data is stale, permit
|
|
130
|
+
// a resume attempt only when the player was restored from persisted state
|
|
131
|
+
// (player._resuming === true).
|
|
132
|
+
if (this._destroyed) return false
|
|
133
|
+
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return false
|
|
134
|
+
if (this._stateFlags & (STATE_FLAGS.ATTEMPTING_RESUME | STATE_FLAGS.DISCONNECTING)) return false
|
|
135
|
+
|
|
136
|
+
if (this._hasValidVoiceData()) return true
|
|
137
|
+
|
|
138
|
+
// If voice data is stale, allow a single resume flow for restored players
|
|
139
|
+
return !!this._player?._resuming
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
setServerUpdate (data) {
|
|
@@ -236,9 +244,38 @@ class Connection {
|
|
|
236
244
|
async attemptResume () {
|
|
237
245
|
if (!this._canAttemptResume()) {
|
|
238
246
|
this._aqua.emit(AqualinkEvents.Debug, `Resume blocked: destroyed=${this._destroyed}, hasValidData=${this._hasValidVoiceData()}, attempts=${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`)
|
|
239
|
-
|
|
247
|
+
|
|
248
|
+
// If voice data is stale (likely after a restart) and this player is
|
|
249
|
+
// marked as resuming, request a fresh voice state update and schedule
|
|
250
|
+
// a retry instead of destroying the player immediately.
|
|
251
|
+
if ((this._stateFlags & STATE_FLAGS.VOICE_DATA_STALE) && this._player?._resuming) {
|
|
252
|
+
try {
|
|
253
|
+
this._aqua.emit(AqualinkEvents.Debug, `Requesting fresh voice state for guild ${this._guildId}`)
|
|
254
|
+
if (typeof this._player.send === 'function' && this._player.voiceChannel) {
|
|
255
|
+
this._player.send({ guild_id: this._guildId, channel_id: this._player.voiceChannel, self_deaf: this._player.deaf, self_mute: this._player.mute })
|
|
256
|
+
this._reconnectTimer = setTimeout(this._handleReconnect, 1500)
|
|
257
|
+
helpers.safeUnref(this._reconnectTimer)
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {}
|
|
260
|
+
} else if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
240
261
|
this._handleDisconnect()
|
|
241
262
|
}
|
|
263
|
+
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// If the player was restored from disk but core voice data is still missing,
|
|
268
|
+
// request a voice state update and retry shortly rather than attempting a
|
|
269
|
+
// resume payload that will fail.
|
|
270
|
+
if ((!this.sessionId || !this.endpoint || !this.token) && this._player?._resuming) {
|
|
271
|
+
try {
|
|
272
|
+
this._aqua.emit(AqualinkEvents.Debug, `Resuming player but voice data missing, requesting voice state for guild ${this._guildId}`)
|
|
273
|
+
if (typeof this._player.send === 'function' && this._player.voiceChannel) {
|
|
274
|
+
this._player.send({ guild_id: this._guildId, channel_id: this._player.voiceChannel, self_deaf: this._player.deaf, self_mute: this._player.mute })
|
|
275
|
+
this._reconnectTimer = setTimeout(this._handleReconnect, 1500)
|
|
276
|
+
helpers.safeUnref(this._reconnectTimer)
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {}
|
|
242
279
|
return false
|
|
243
280
|
}
|
|
244
281
|
|
|
@@ -263,6 +300,7 @@ class Connection {
|
|
|
263
300
|
|
|
264
301
|
this._reconnectAttempts = 0
|
|
265
302
|
this._consecutiveFailures = 0
|
|
303
|
+
if (this._player) this._player._resuming = false
|
|
266
304
|
this._aqua.emit(AqualinkEvents.Debug, `Resume successful for guild ${this._guildId}`)
|
|
267
305
|
return true
|
|
268
306
|
} catch (error) {
|
|
@@ -275,6 +313,7 @@ class Connection {
|
|
|
275
313
|
helpers.safeUnref(this._reconnectTimer)
|
|
276
314
|
} else {
|
|
277
315
|
this._aqua.emit(AqualinkEvents.Debug, `Max reconnect attempts or failures reached for guild ${this._guildId}`)
|
|
316
|
+
if (this._player) this._player._resuming = false
|
|
278
317
|
this._handleDisconnect()
|
|
279
318
|
}
|
|
280
319
|
|