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