aqualink 2.10.1 → 2.11.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,26 +1,24 @@
1
- 'use strict';
1
+ 'use strict'
2
2
 
3
- const { promises: fs } = require('node:fs');
4
- const { EventEmitter } = require('tseep');
3
+ const fs = require('node:fs')
4
+ const readline = require('node:readline')
5
+ const { EventEmitter } = require('tseep')
5
6
 
6
- const Node = require('./Node');
7
- const Player = require('./Player');
8
- const Track = require('./Track');
9
- const { version: pkgVersion } = require('../../package.json');
7
+ const Node = require('./Node')
8
+ const Player = require('./Player')
9
+ const Track = require('./Track')
10
+ const { version: pkgVersion } = require('../../package.json')
10
11
 
11
- const URL_PATTERN = /^https?:\/\//;
12
- const SEARCH_PREFIX = ':';
13
- const REQUESTER_PATTERN = /^([^:]+):(.+)$/;
14
-
15
- const EMPTY_ARRAY = Object.freeze([]);
12
+ // Constants
13
+ const SEARCH_PREFIX = ':'
14
+ const EMPTY_ARRAY = Object.freeze([])
16
15
  const EMPTY_TRACKS_RESPONSE = Object.freeze({
17
16
  loadType: 'empty',
18
17
  exception: null,
19
18
  playlistInfo: null,
20
19
  pluginInfo: {},
21
20
  tracks: EMPTY_ARRAY
22
- });
23
-
21
+ })
24
22
 
25
23
  const DEFAULT_OPTIONS = Object.freeze({
26
24
  shouldDeleteMessage: false,
@@ -39,365 +37,454 @@ const DEFAULT_OPTIONS = Object.freeze({
39
37
  cooldownTime: 5000,
40
38
  maxFailoverAttempts: 5
41
39
  })
42
- });
40
+ })
41
+
42
+ const CLEANUP_INTERVAL = 180000 // 3m
43
+ const MAX_CONCURRENT_OPS = 10
44
+ const BROKEN_PLAYER_TTL = 300000 // 5m
45
+ const FAILOVER_CLEANUP_TTL = 600000 // 10m
46
+ const PLAYER_BATCH_SIZE = 20
47
+ const SEEK_DELAY = 120
48
+ const RECONNECT_DELAY = 400
49
+ const CACHE_VALID_TIME = 12000 // 12s
50
+ const NODE_TIMEOUT = 30000
43
51
 
44
- const CLEANUP_INTERVAL = 60000;
45
- const MAX_CONCURRENT_OPS = 4;
46
- const BROKEN_PLAYER_TTL = 300000;
47
- const FAILOVER_CLEANUP_TTL = 600000;
48
- const NODE_BATCH_SIZE = 3;
49
- const PLAYER_BATCH_SIZE = 8;
50
- const SEEK_DELAY = 150;
51
- const RECONNECT_DELAY = 800;
52
- const NODE_CHECK_INTERVAL = 100;
53
- const CACHE_VALID_TIME = 5000;
52
+ const URL_PATTERN = /^https?:\/\//i
53
+ const isProbablyUrl = s => typeof s === 'string' && URL_PATTERN.test(s)
54
54
 
55
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
55
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
56
56
 
57
57
  class Aqua extends EventEmitter {
58
58
  constructor(client, nodes, options = {}) {
59
- super();
60
- if (!client) throw new Error('Client is required');
61
- if (!Array.isArray(nodes) || !nodes.length) throw new TypeError('Nodes must be non-empty Array');
62
-
63
- this.client = client;
64
- this.nodes = nodes;
65
- this.nodeMap = new Map();
66
- this.players = new Map();
67
- this.clientId = null;
68
- this.initiated = false;
69
- this.version = pkgVersion;
70
-
71
- this.options = Object.assign({}, DEFAULT_OPTIONS, options);
72
- this.failoverOptions = Object.assign({}, DEFAULT_OPTIONS.failoverOptions, options.failoverOptions);
73
-
74
- Object.assign(this, {
75
- shouldDeleteMessage: this.options.shouldDeleteMessage,
76
- defaultSearchPlatform: this.options.defaultSearchPlatform,
77
- leaveOnEnd: this.options.leaveOnEnd,
78
- restVersion: this.options.restVersion,
79
- plugins: this.options.plugins,
80
- autoResume: this.options.autoResume,
81
- infiniteReconnects: this.options.infiniteReconnects,
82
- send: this.options.send || this._defaultSend
83
- });
84
-
85
- this._nodeStates = new Map();
86
- this._failoverQueue = new Map();
87
- this._lastFailoverAttempt = new Map();
88
- this._brokenPlayers = new Map();
89
-
90
- this._leastUsedNodesCache = null;
91
- this._leastUsedNodesCacheTime = 0;
92
-
93
- this._bindEventHandlers();
94
- this._startCleanupTimer();
95
- }
96
-
97
- _defaultSend = packet => {
98
- const guildId = packet.d.guild_id;
99
- const guild = this.client.cache?.guilds.get(guildId) ?? this.client.guilds?.cache?.get(guildId);
100
- if (!guild) return;
101
-
102
- const gateway = this.client.gateway;
103
- if (gateway) {
104
- gateway.send(gateway.calculateShardId(guildId), packet);
105
- } else if (guild.shard) {
106
- guild.shard.send(packet);
107
- }
108
- };
59
+ super()
60
+ if (!client) throw new Error('Client is required')
61
+ if (!Array.isArray(nodes) || !nodes.length) throw new TypeError('Nodes must be non-empty Array')
62
+
63
+ this.client = client
64
+ this.nodes = nodes
65
+ this.nodeMap = new Map()
66
+ this.players = new Map()
67
+ this.clientId = null
68
+ this.initiated = false
69
+ this.version = pkgVersion
70
+
71
+ this.options = Object.assign({}, DEFAULT_OPTIONS, options)
72
+ this.failoverOptions = Object.assign({}, DEFAULT_OPTIONS.failoverOptions, options.failoverOptions)
73
+ this.shouldDeleteMessage = this.options.shouldDeleteMessage
74
+ this.defaultSearchPlatform = this.options.defaultSearchPlatform
75
+ this.leaveOnEnd = this.options.leaveOnEnd
76
+ this.restVersion = this.options.restVersion || 'v4'
77
+ this.plugins = this.options.plugins
78
+ this.autoResume = this.options.autoResume
79
+ this.infiniteReconnects = this.options.infiniteReconnects
80
+ this.send = this.options.send || this._createDefaultSend()
81
+
82
+ this._nodeStates = new Map() // nodeId -> { connected, failoverInProgress }
83
+ this._failoverQueue = new Map() // nodeId -> attempts
84
+ this._lastFailoverAttempt = new Map() // nodeId -> timestamp
85
+ this._brokenPlayers = new Map() // guildId -> capturedState
86
+ this._rebuildLocks = new Set() // guild-level lock for rebuilds
87
+
88
+ this._leastUsedNodesCache = null
89
+ this._leastUsedNodesCacheTime = 0
90
+ this._nodeLoadCache = new Map()
91
+ this._nodeLoadCacheTime = new Map()
92
+
93
+ this._bindEventHandlers()
94
+ this._startCleanupTimer()
95
+ }
96
+
97
+ _createDefaultSend() {
98
+ return packet => {
99
+ const guildId = packet?.d?.guild_id
100
+ if (!guildId) return
101
+
102
+ const guild = this.client.cache?.guilds?.get?.(guildId) ?? this.client.guilds?.cache?.get?.(guildId)
103
+ if (!guild) return
104
+
105
+ const gateway = this.client.gateway
106
+ if (gateway?.send) {
107
+ gateway.send(gateway.calculateShardId(guildId), packet)
108
+ } else if (guild.shard?.send) {
109
+ guild.shard.send(packet)
110
+ }
111
+ }
112
+ }
109
113
 
110
114
  _bindEventHandlers() {
111
- this.on('nodeConnect', node => this.autoResume && queueMicrotask(() => this._rebuildBrokenPlayers(node)));
112
- this.on('nodeDisconnect', node => this.autoResume && queueMicrotask(() => this._storeBrokenPlayers(node)));
115
+ if (!this.autoResume) return
116
+
117
+ this._onNodeConnect = node => queueMicrotask(() => {
118
+ this._invalidateCache()
119
+ this._rebuildBrokenPlayers(node)
120
+ })
121
+ this._onNodeDisconnect = node => queueMicrotask(() => {
122
+ this._invalidateCache()
123
+ this._storeBrokenPlayers(node)
124
+ })
125
+
126
+ this.on('nodeConnect', this._onNodeConnect)
127
+ this.on('nodeDisconnect', this._onNodeDisconnect)
113
128
  }
114
129
 
115
130
  _startCleanupTimer() {
116
- this._cleanupTimer = setInterval(() => this._performCleanup(), CLEANUP_INTERVAL);
117
- this._cleanupTimer.unref();
131
+ this._cleanupTimer = setInterval(() => this._performCleanup(), CLEANUP_INTERVAL)
132
+ this._cleanupTimer.unref?.()
118
133
  }
119
134
 
120
135
  get leastUsedNodes() {
121
- const now = Date.now();
136
+ const now = Date.now()
122
137
  if (this._leastUsedNodesCache && (now - this._leastUsedNodesCacheTime) < CACHE_VALID_TIME) {
123
- return this._leastUsedNodesCache;
138
+ return this._leastUsedNodesCache
124
139
  }
125
140
 
126
- const connectedNodes = [];
141
+ const connected = []
127
142
  for (const node of this.nodeMap.values()) {
128
- if (node.connected) connectedNodes.push(node);
143
+ if (node.connected) connected.push(node)
129
144
  }
130
145
 
131
- if (connectedNodes.length > 1) {
132
- for (let i = 1; i < connectedNodes.length; i++) {
133
- const current = connectedNodes[i];
134
- const calls = current.rest?.calls || 0;
135
- let j = i - 1;
146
+ connected.sort((a, b) => this._getCachedNodeLoad(a) - this._getCachedNodeLoad(b))
136
147
 
137
- while (j >= 0 && (connectedNodes[j].rest?.calls || 0) > calls) {
138
- connectedNodes[j + 1] = connectedNodes[j];
139
- j--;
140
- }
141
- connectedNodes[j + 1] = current;
142
- }
143
- }
148
+ this._leastUsedNodesCache = Object.freeze(connected.slice())
149
+ this._leastUsedNodesCacheTime = now
150
+ return this._leastUsedNodesCache
151
+ }
144
152
 
145
- this._leastUsedNodesCache = connectedNodes;
146
- this._leastUsedNodesCacheTime = now;
147
- return connectedNodes;
153
+ _invalidateCache() {
154
+ this._leastUsedNodesCache = null
155
+ this._leastUsedNodesCacheTime = 0
148
156
  }
149
157
 
150
- async init(clientId) {
151
- if (this.initiated) return this;
152
- this.clientId = clientId;
153
- let successCount = 0;
158
+ _getCachedNodeLoad(node) {
159
+ const nodeId = node.name || node.host
160
+ const now = Date.now()
161
+ const cacheTime = this._nodeLoadCacheTime.get(nodeId)
154
162
 
155
- for (let i = 0; i < this.nodes.length; i += NODE_BATCH_SIZE) {
156
- const batch = this.nodes.slice(i, i + NODE_BATCH_SIZE);
157
- successCount += await this._processNodeBatch(batch);
163
+ if (cacheTime && (now - cacheTime) < 5000) {
164
+ return this._nodeLoadCache.get(nodeId) || 0
158
165
  }
159
166
 
160
- if (!successCount) throw new Error('No nodes connected');
167
+ const load = this._calculateNodeLoad(node)
168
+ this._nodeLoadCache.set(nodeId, load)
169
+ this._nodeLoadCacheTime.set(nodeId, now)
170
+ return load
171
+ }
172
+
173
+ _calculateNodeLoad(node) {
174
+ const stats = node?.stats
175
+ if (!stats) return 0
176
+
177
+ const cpu = stats.cpu
178
+ const cores = Math.max(1, cpu?.cores || 1)
179
+ const cpuLoad = cpu ? (cpu.systemLoad / cores) : 0
161
180
 
162
- await this._loadPlugins();
163
- this.initiated = true;
164
- return this;
181
+ const playing = stats.playingPlayers || 0
182
+
183
+ const memory = stats.memory
184
+ const memoryUsage = memory ? (memory.used / Math.max(1, memory.reservable)) : 0
185
+
186
+ const restCalls = node?.rest?.calls || 0
187
+
188
+ return (cpuLoad * 100) + (playing * 0.75) + (memoryUsage * 40) + (restCalls * 0.001)
165
189
  }
166
190
 
167
- async _processNodeBatch(batch) {
168
- const results = await Promise.allSettled(batch.map(n => this._createNode(n)));
169
- return results.filter(r => r.status === 'fulfilled').length;
191
+ async init(clientId) {
192
+ if (this.initiated) return this
193
+ this.clientId = clientId
194
+
195
+ if (!this.clientId) return
196
+
197
+ const results = await Promise.allSettled(
198
+ this.nodes.map(n =>
199
+ Promise.race([
200
+ this._createNode(n),
201
+ new Promise((_, reject) =>
202
+ setTimeout(() => reject(new Error('Node timeout')), NODE_TIMEOUT)
203
+ )
204
+ ])
205
+ )
206
+ )
207
+
208
+ const successCount = results.filter(r => r.status === 'fulfilled').length
209
+ if (!successCount) throw new Error('No nodes connected')
210
+
211
+ await this._loadPlugins()
212
+ this.initiated = true
213
+ return this
170
214
  }
171
215
 
172
216
  async _loadPlugins() {
173
- const pluginPromises = this.plugins.map(plugin => {
174
- return plugin.load(this).catch(err => {
175
- this.emit('error', null, new Error(`Plugin error: ${err.message}`));
176
- });
177
- });
178
- await Promise.all(pluginPromises);
217
+ if (!this.plugins?.length) return
218
+ await Promise.allSettled(
219
+ this.plugins.map(async plugin => {
220
+ try {
221
+ await plugin.load(this)
222
+ } catch (err) {
223
+ this.emit('error', null, new Error(`Plugin error: ${err?.message || String(err)}`))
224
+ }
225
+ })
226
+ )
179
227
  }
180
228
 
181
229
  async _createNode(options) {
182
- const nodeId = options.name || options.host;
183
- this._destroyNode(nodeId);
230
+ const nodeId = options.name || options.host
231
+ this._destroyNode(nodeId)
184
232
 
185
- const node = new Node(this, options, this.options);
186
- node.players = new Set();
233
+ const node = new Node(this, options, this.options)
234
+ if (!node.players) node.players = new Set()
187
235
 
188
- this.nodeMap.set(nodeId, node);
189
- this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
236
+ this.nodeMap.set(nodeId, node)
237
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false })
190
238
 
191
239
  try {
192
- node.connect();
193
- this._nodeStates.set(nodeId, { connected: true, failoverInProgress: false });
194
- this.emit('nodeCreate', node);
195
- return node;
240
+ await node.connect()
241
+ this._nodeStates.set(nodeId, { connected: true, failoverInProgress: false })
242
+ this._invalidateCache()
243
+ this.emit('nodeCreate', node)
244
+ return node
196
245
  } catch (error) {
197
- this._cleanupNode(nodeId);
198
- throw error;
246
+ this._cleanupNode(nodeId)
247
+ throw error
248
+ }
249
+ }
250
+
251
+ _destroyNode(identifier) {
252
+ const node = this.nodeMap.get(identifier)
253
+ if (node) {
254
+ try { node.destroy?.() } catch {}
255
+ this._cleanupNode(identifier)
256
+ this.emit('nodeDestroy', node)
257
+ }
258
+ }
259
+
260
+ _cleanupNode(nodeId) {
261
+ const node = this.nodeMap.get(nodeId)
262
+ if (node) {
263
+ node.removeAllListeners?.()
264
+ node.players?.clear?.()
265
+ this.nodeMap.delete(nodeId)
266
+ }
267
+
268
+ this._nodeStates.delete(nodeId)
269
+ this._failoverQueue.delete(nodeId)
270
+ this._lastFailoverAttempt.delete(nodeId)
271
+ this._nodeLoadCache.delete(nodeId)
272
+ this._nodeLoadCacheTime.delete(nodeId)
273
+
274
+ if (this._leastUsedNodesCache?.some?.(n => (n.name || n.host) === nodeId)) {
275
+ this._invalidateCache()
199
276
  }
200
277
  }
201
278
 
202
279
  _storeBrokenPlayers(node) {
203
- const nodeId = node.name || node.host;
204
- const now = Date.now();
280
+ const nodeId = node.name || node.host
281
+ const now = Date.now()
282
+ const brokenStates = []
205
283
 
206
284
  for (const player of this.players.values()) {
207
- if (player.nodes !== node) continue;
208
-
209
- const state = this._capturePlayerState(player);
285
+ if (player.nodes !== node) continue
286
+ const state = this._capturePlayerState(player)
210
287
  if (state) {
211
- state.originalNodeId = nodeId;
212
- state.brokenAt = now;
213
- this._brokenPlayers.set(player.guildId, state);
288
+ state.originalNodeId = nodeId
289
+ state.brokenAt = now
290
+ brokenStates.push([player.guildId, state])
214
291
  }
215
292
  }
293
+
294
+ for (const [guildId, state] of brokenStates) {
295
+ this._brokenPlayers.set(guildId, state)
296
+ }
216
297
  }
217
298
 
218
- _rebuildBrokenPlayers(node) {
219
- const nodeId = node.name || node.host;
220
- let rebuiltCount = 0;
221
- const toDelete = [];
299
+ async _rebuildBrokenPlayers(node) {
300
+ const nodeId = node.name || node.host
301
+ const rebuilds = []
222
302
 
223
303
  for (const [guildId, brokenState] of this._brokenPlayers) {
224
- if (brokenState.originalNodeId !== nodeId) continue;
304
+ if (brokenState.originalNodeId !== nodeId) continue
305
+ if (Date.now() - brokenState.brokenAt > BROKEN_PLAYER_TTL) continue
306
+ rebuilds.push({ guildId, brokenState })
307
+ }
225
308
 
226
- this._rebuildPlayer(brokenState, node)
227
- .then(() => {
228
- toDelete.push(guildId);
229
- rebuiltCount++;
230
- })
231
- .catch(() => {
232
- if (Date.now() - brokenState.brokenAt > BROKEN_PLAYER_TTL) {
233
- toDelete.push(guildId);
234
- }
235
- });
309
+ if (!rebuilds.length) return
310
+
311
+ const batchSize = Math.min(MAX_CONCURRENT_OPS, rebuilds.length)
312
+ const successes = []
313
+
314
+ for (let i = 0; i < rebuilds.length; i += batchSize) {
315
+ const batch = rebuilds.slice(i, i + batchSize)
316
+ const results = await Promise.allSettled(
317
+ batch.map(({ guildId, brokenState }) =>
318
+ this._rebuildPlayer(brokenState, node).then(() => guildId)
319
+ )
320
+ )
321
+ for (const r of results) {
322
+ if (r.status === 'fulfilled') successes.push(r.value)
323
+ }
236
324
  }
237
325
 
238
- for (const guildId of toDelete) {
239
- this._brokenPlayers.delete(guildId);
326
+ for (const guildId of successes) {
327
+ this._brokenPlayers.delete(guildId)
240
328
  }
241
329
 
242
- if (rebuiltCount) this.emit('playersRebuilt', node, rebuiltCount);
330
+ if (successes.length) this.emit('playersRebuilt', node, successes.length)
243
331
  }
244
332
 
245
333
  async _rebuildPlayer(brokenState, targetNode) {
246
- const { guildId, textChannel, voiceChannel, current, volume = 65, deaf = true } = brokenState;
247
- const existingPlayer = this.players.get(guildId);
248
- if (existingPlayer) await existingPlayer.destroy();
249
-
250
- await delay(RECONNECT_DELAY);
334
+ const { guildId, textChannel, voiceChannel, current, volume = 65, deaf = true } = brokenState
335
+ const lockKey = `rebuild_${guildId}`
336
+ if (this._rebuildLocks.has(lockKey)) return
337
+ this._rebuildLocks.add(lockKey)
251
338
 
252
339
  try {
253
- const player = await this.createConnection({
340
+ const existing = this.players.get(guildId)
341
+ if (existing) {
342
+ await this.destroyPlayer(guildId)
343
+ await delay(RECONNECT_DELAY)
344
+ }
345
+
346
+ const player = this.createPlayer(targetNode, {
254
347
  guildId,
255
348
  textChannel,
256
349
  voiceChannel,
257
350
  defaultVolume: volume,
258
351
  deaf
259
- });
352
+ })
353
+
354
+ if (current && player?.queue?.add) {
355
+ player.queue.add(current)
356
+ await player.play()
260
357
 
261
- if (current) {
262
- await player.queue.add(current);
263
- await player.play();
264
358
  if (brokenState.position > 0) {
265
- setTimeout(() => player.seek(brokenState.position), SEEK_DELAY);
359
+ setTimeout(() => player.seek?.(brokenState.position), SEEK_DELAY)
360
+ }
361
+
362
+ if (brokenState.paused) {
363
+ await player.pause(true)
266
364
  }
267
- if (brokenState.paused) player.pause();
268
- this.emit('trackStart', player, current);
269
365
  }
270
- } catch {
271
- this._brokenPlayers.delete(guildId);
272
- }
273
- }
274
366
 
275
- _destroyNode(identifier) {
276
- const node = this.nodeMap.get(identifier);
277
- if (node) {
278
- this._cleanupNode(identifier);
279
- this.emit('nodeDestroy', node);
367
+ return player
368
+ } finally {
369
+ this._rebuildLocks.delete(lockKey)
280
370
  }
281
371
  }
282
372
 
283
- _cleanupNode(nodeId) {
284
- const node = this.nodeMap.get(nodeId);
285
- if (node) {
286
- node.removeAllListeners();
287
- this.nodeMap.delete(nodeId);
288
- }
289
-
290
- this._nodeStates.delete(nodeId);
291
- this._failoverQueue.delete(nodeId);
292
- this._lastFailoverAttempt.delete(nodeId);
293
- this._invalidateCache();
294
- }
295
-
296
- _invalidateCache() {
297
- this._leastUsedNodesCache = null;
298
- this._leastUsedNodesCacheTime = 0;
299
- }
300
-
301
373
  async handleNodeFailover(failedNode) {
302
- if (!this.failoverOptions.enabled) return;
374
+ if (!this.failoverOptions.enabled) return
303
375
 
304
- const nodeId = failedNode.name || failedNode.host;
305
- const now = Date.now();
306
- const nodeState = this._nodeStates.get(nodeId);
376
+ const nodeId = failedNode.name || failedNode.host
377
+ const now = Date.now()
307
378
 
308
- if (nodeState?.failoverInProgress) return;
379
+ const nodeState = this._nodeStates.get(nodeId)
380
+ if (nodeState?.failoverInProgress) return
309
381
 
310
- const lastAttempt = this._lastFailoverAttempt.get(nodeId);
311
- if (lastAttempt && (now - lastAttempt) < this.failoverOptions.cooldownTime) return;
382
+ const lastAttempt = this._lastFailoverAttempt.get(nodeId)
383
+ if (lastAttempt && (now - lastAttempt) < this.failoverOptions.cooldownTime) return
312
384
 
313
- const attempts = this._failoverQueue.get(nodeId) || 0;
314
- if (attempts >= this.failoverOptions.maxFailoverAttempts) return;
385
+ const attempts = this._failoverQueue.get(nodeId) || 0
386
+ if (attempts >= this.failoverOptions.maxFailoverAttempts) return
315
387
 
316
- this._nodeStates.set(nodeId, { connected: false, failoverInProgress: true });
317
- this._lastFailoverAttempt.set(nodeId, now);
318
- this._failoverQueue.set(nodeId, attempts + 1);
388
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: true })
389
+ this._lastFailoverAttempt.set(nodeId, now)
390
+ this._failoverQueue.set(nodeId, attempts + 1)
319
391
 
320
392
  try {
321
- this.emit('nodeFailover', failedNode);
322
- const affectedPlayers = Array.from(failedNode.players);
393
+ this.emit('nodeFailover', failedNode)
323
394
 
395
+ const affectedPlayers = Array.from(failedNode.players || [])
324
396
  if (!affectedPlayers.length) {
325
- this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
326
- return;
397
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false })
398
+ return
327
399
  }
328
400
 
329
- const availableNodes = this._getAvailableNodes(failedNode);
330
- if (!availableNodes.length) {
331
- this.emit('error', null, new Error('No failover nodes available'));
332
- return;
333
- }
401
+ const availableNodes = this._getAvailableNodes(failedNode)
402
+ if (!availableNodes.length) throw new Error('No failover nodes available')
334
403
 
335
- const results = await this._migratePlayersOptimized(affectedPlayers, availableNodes);
336
- const successful = results.filter(r => r.success).length;
404
+ const results = await this._migratePlayersOptimized(affectedPlayers, availableNodes)
405
+ const successful = results.filter(r => r.success).length
337
406
 
338
407
  if (successful) {
339
- this.emit('nodeFailoverComplete', failedNode, successful, results.length - successful);
408
+ this.emit('nodeFailoverComplete', failedNode, successful, results.length - successful)
340
409
  }
341
410
  } catch (error) {
342
- this.emit('error', null, new Error(`Failover failed: ${error.message}`));
411
+ this.emit('error', null, new Error(`Failover failed: ${error?.message || String(error)}`))
343
412
  } finally {
344
- this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
413
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false })
345
414
  }
346
415
  }
347
416
 
348
417
  async _migratePlayersOptimized(players, availableNodes) {
349
- const results = [];
350
- for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
351
- const batch = players.slice(i, i + MAX_CONCURRENT_OPS);
418
+ const baseLoads = new Map()
419
+ const assignedCounts = new Map()
420
+ for (const n of availableNodes) {
421
+ baseLoads.set(n, this._getCachedNodeLoad(n))
422
+ assignedCounts.set(n, 0)
423
+ }
424
+ const pickNode = () => {
425
+ let best = null
426
+ let bestScore = Infinity
427
+ for (const n of availableNodes) {
428
+ const score = baseLoads.get(n) + (assignedCounts.get(n) || 0)
429
+ if (score < bestScore) {
430
+ bestScore = score
431
+ best = n
432
+ }
433
+ }
434
+ assignedCounts.set(best, (assignedCounts.get(best) || 0) + 1)
435
+ return best
436
+ }
437
+
438
+ const batchSize = Math.min(MAX_CONCURRENT_OPS, players.length)
439
+ const results = []
440
+
441
+ for (let i = 0; i < players.length; i += batchSize) {
442
+ const batch = players.slice(i, i + batchSize)
352
443
  const batchResults = await Promise.allSettled(
353
- batch.map(p => this._migratePlayer(p, availableNodes))
354
- );
444
+ batch.map(p => this._migratePlayer(p, pickNode))
445
+ )
355
446
  results.push(...batchResults.map(r => ({
356
447
  success: r.status === 'fulfilled',
357
448
  error: r.reason
358
- })));
449
+ })))
359
450
  }
360
- return results;
451
+
452
+ return results
361
453
  }
362
454
 
363
- async _migratePlayer(player, availableNodes) {
364
- let retryCount = 0;
365
- while (retryCount < this.failoverOptions.maxRetries) {
455
+ async _migratePlayer(player, pickNode) {
456
+ const playerState = this._capturePlayerState(player)
457
+ if (!playerState) throw new Error('Failed to capture state')
458
+
459
+ for (let retry = 0; retry < this.failoverOptions.maxRetries; retry++) {
366
460
  try {
367
- const targetNode = availableNodes[0];
368
- const playerState = this._capturePlayerState(player);
369
- if (!playerState) throw new Error('Failed to capture state');
370
-
371
- const newPlayer = await this._createPlayerOnNode(targetNode, playerState);
372
- await this._restorePlayerState(newPlayer, playerState);
373
- this.emit('playerMigrated', player, newPlayer, targetNode);
374
- return newPlayer;
461
+ const targetNode = pickNode()
462
+ const newPlayer = await this._createPlayerOnNode(targetNode, playerState)
463
+ await this._restorePlayerState(newPlayer, playerState)
464
+ this.emit('playerMigrated', player, newPlayer, targetNode)
465
+ return newPlayer
375
466
  } catch (error) {
376
- retryCount++;
377
- if (retryCount >= this.failoverOptions.maxRetries) throw error;
378
- await delay(this.failoverOptions.retryDelay);
467
+ if (retry === this.failoverOptions.maxRetries - 1) throw error
468
+ await delay(this.failoverOptions.retryDelay * Math.pow(1.5, retry))
379
469
  }
380
470
  }
381
471
  }
382
472
 
383
473
  _capturePlayerState(player) {
384
- try {
385
- return {
386
- guildId: player.guildId,
387
- textChannel: player.textChannel,
388
- voiceChannel: player.voiceChannel,
389
- volume: player.volume || 100,
390
- paused: player.paused || false,
391
- position: player.position || 0,
392
- current: player.current || null,
393
- queue: player.queue?.tracks?.slice(0, 10) || EMPTY_ARRAY,
394
- repeat: player.loop,
395
- shuffle: player.shuffle,
396
- deaf: player.deaf || false,
397
- connected: player.connected || false
398
- };
399
- } catch {
400
- return null;
474
+ if (!player) return null
475
+ return {
476
+ guildId: player.guildId,
477
+ textChannel: player.textChannel,
478
+ voiceChannel: player.voiceChannel,
479
+ volume: player.volume ?? 100,
480
+ paused: !!player.paused,
481
+ position: player.position || 0,
482
+ current: player.current || null,
483
+ queue: player.queue?.tracks?.slice(0, 50) || EMPTY_ARRAY,
484
+ repeat: player.loop,
485
+ shuffle: player.shuffle,
486
+ deaf: player.deaf ?? false,
487
+ connected: !!player.connected
401
488
  }
402
489
  }
403
490
 
@@ -408,156 +495,204 @@ class Aqua extends EventEmitter {
408
495
  voiceChannel: playerState.voiceChannel,
409
496
  defaultVolume: playerState.volume || 100,
410
497
  deaf: playerState.deaf || false
411
- });
498
+ })
412
499
  }
413
500
 
414
501
  async _restorePlayerState(newPlayer, playerState) {
415
- if (playerState.volume !== undefined) newPlayer.setVolume(playerState.volume);
416
- if (playerState.queue?.length) newPlayer.queue.add(...playerState.queue);
502
+ const operations = []
503
+
504
+ if (typeof playerState.volume === 'number') {
505
+ if (typeof newPlayer.setVolume === 'function') {
506
+ operations.push(newPlayer.setVolume(playerState.volume))
507
+ } else {
508
+ newPlayer.volume = playerState.volume
509
+ }
510
+ }
511
+
512
+ if (playerState.queue?.length && newPlayer.queue?.add) {
513
+ newPlayer.queue.add(...playerState.queue)
514
+ }
417
515
 
418
516
  if (playerState.current && this.failoverOptions.preservePosition) {
419
- newPlayer.queue.unshift(playerState.current);
517
+ if (newPlayer.queue?.add) {
518
+ newPlayer.queue.add(playerState.current, { toFront: true })
519
+ }
420
520
  if (this.failoverOptions.resumePlayback) {
421
- await newPlayer.play();
521
+ operations.push(newPlayer.play())
422
522
  if (playerState.position > 0) {
423
- setTimeout(() => newPlayer.seek(playerState.position), SEEK_DELAY);
523
+ setTimeout(() => newPlayer.seek?.(playerState.position), SEEK_DELAY)
524
+ }
525
+ if (playerState.paused) {
526
+ operations.push(newPlayer.pause(true))
424
527
  }
425
- if (playerState.paused) newPlayer.pause();
426
528
  }
427
529
  }
428
530
 
429
- newPlayer.repeat = playerState.repeat;
430
- newPlayer.shuffle = playerState.shuffle;
431
- }
531
+ Object.assign(newPlayer, {
532
+ repeat: playerState.repeat,
533
+ shuffle: playerState.shuffle
534
+ })
432
535
 
433
- _cleanupPlayer(player) {
434
- if (!player) return;
435
- player.destroy();
436
- player.voiceChannel = null;
437
- this.emit('playerDestroy', player);
536
+ await Promise.allSettled(operations)
438
537
  }
439
538
 
440
539
  updateVoiceState({ d, t }) {
441
- if (!d.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')) return;
540
+ if (!d?.guild_id) return
541
+ if (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE') return
442
542
 
443
- const player = this.players.get(d.guild_id);
444
- if (!player) return;
543
+ const player = this.players.get(d.guild_id)
544
+ if (!player) return
445
545
 
446
546
  if (t === 'VOICE_STATE_UPDATE') {
447
- if (d.user_id !== this.clientId) return;
448
- if (!d.channel_id) return this._cleanupPlayer(player);
547
+ if (d.user_id !== this.clientId) return
449
548
 
450
- if (player.connection && !player.connection.sessionId && d.session_id) {
451
- player.connection.sessionId = d.session_id;
452
- return;
549
+ if (!d.channel_id) {
550
+ this.destroyPlayer(d.guild_id)
551
+ return
453
552
  }
454
553
 
455
- if (player.connection && d.session_id && player.connection.sessionId !== d.session_id) {
456
- player.connection.sessionId = d.session_id;
457
- this.emit('debug', `[Player ${player.guildId}] Session updated to ${d.session_id}`);
554
+ if (player.connection) {
555
+ player.connection.sessionId = d.session_id
556
+ player.connection.setStateUpdate(d)
458
557
  }
459
-
460
- player.connection?.setStateUpdate(d);
461
558
  } else {
462
- player.connection?.setServerUpdate(d);
559
+ player.connection?.setServerUpdate(d)
463
560
  }
464
561
  }
465
562
 
466
563
  fetchRegion(region) {
467
- if (!region) return this.leastUsedNodes;
468
-
469
- const lowerRegion = region.toLowerCase();
470
- const filtered = [];
471
-
564
+ if (!region) return this.leastUsedNodes
565
+ const lowerRegion = region.toLowerCase()
566
+ const filtered = []
472
567
  for (const node of this.nodeMap.values()) {
473
- if (node.connected && node.regions?.includes(lowerRegion)) {
474
- filtered.push(node);
475
- }
568
+ if (node.connected && node.regions?.includes(lowerRegion)) filtered.push(node)
476
569
  }
477
-
478
- return filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b));
479
- }
480
-
481
- _getNodeLoad(node) {
482
- const stats = node?.stats?.cpu;
483
- return stats ? (stats.systemLoad / stats.cores) * 100 : 0;
570
+ filtered.sort((a, b) => this._getCachedNodeLoad(a) - this._getCachedNodeLoad(b))
571
+ return Object.freeze(filtered.slice())
484
572
  }
485
573
 
486
574
  createConnection(options) {
487
- if (!this.initiated) throw new Error('Aqua not initialized');
575
+ if (!this.initiated) throw new Error('Aqua not initialized')
488
576
 
489
- const existingPlayer = this.players.get(options.guildId);
490
- if (existingPlayer?.voiceChannel) return existingPlayer;
577
+ const existing = this.players.get(options.guildId)
578
+ if (existing) {
579
+ if (options.voiceChannel && existing.voiceChannel !== options.voiceChannel) {
580
+ try { existing.connect(options) } catch {}
581
+ }
582
+ return existing
583
+ }
491
584
 
492
- const availableNodes = options.region ?
493
- this.fetchRegion(options.region) :
494
- this.leastUsedNodes;
585
+ const candidateNodes = options.region ? this.fetchRegion(options.region) : this.leastUsedNodes
586
+ if (!candidateNodes.length) throw new Error('No nodes available')
495
587
 
496
- if (!availableNodes.length) throw new Error('No nodes available');
588
+ const node = this._chooseLeastBusyNode(candidateNodes)
589
+ if (!node) throw new Error('No suitable node found')
497
590
 
498
- return this.createPlayer(availableNodes[0], options);
591
+ return this.createPlayer(node, options)
499
592
  }
500
593
 
501
594
  createPlayer(node, options) {
502
- this.destroyPlayer(options.guildId);
503
- const player = new Player(this, node, options);
504
- this.players.set(options.guildId, player);
505
- player.once('destroy', this._handlePlayerDestroy.bind(this));
506
- player.connect(options);
507
- this.emit('playerCreate', player);
508
- return player;
595
+ const existing = this.players.get(options.guildId)
596
+ if (existing) {
597
+ try { existing.destroy?.() } catch {}
598
+ }
599
+
600
+ const player = new Player(this, node, options)
601
+ this.players.set(options.guildId, player)
602
+ node?.players?.add?.(player)
603
+
604
+ player.once('destroy', () => this._handlePlayerDestroy(player))
605
+ player.connect(options)
606
+ this.emit('playerCreate', player)
607
+ return player
509
608
  }
510
609
 
511
610
  _handlePlayerDestroy(player) {
512
- const node = player.nodes;
513
- node?.players?.delete(player);
514
- this.players.delete(player.guildId);
515
- this.emit('playerDestroy', player);
611
+ const node = player.nodes
612
+ node?.players?.delete?.(player)
613
+
614
+ if (this.players.get(player.guildId) === player) {
615
+ this.players.delete(player.guildId)
616
+ }
617
+
618
+ this.emit('playerDestroy', player)
516
619
  }
517
620
 
518
621
  async destroyPlayer(guildId) {
519
- const player = this.players.get(guildId);
520
- if (!player) return;
521
-
622
+ const player = this.players.get(guildId)
623
+ if (!player) return
522
624
  try {
523
- await player.clearData();
524
- player.removeAllListeners();
525
- this.players.delete(guildId);
526
- this.nodes.rest.destroyPlayer(guildId);
527
- this.emit('playerDestroy', player);
528
- } catch { }
625
+ this.players.delete(guildId)
626
+ player.removeAllListeners?.()
627
+ await player.destroy?.()
628
+ } finally {
629
+ // Cleanup is performed by _handlePlayerDestroy via 'destroy' event
630
+ }
529
631
  }
530
632
 
531
633
  async resolve({ query, source = this.defaultSearchPlatform, requester, nodes }) {
532
- if (!this.initiated) throw new Error('Aqua not initialized');
634
+ if (!this.initiated) throw new Error('Aqua not initialized')
635
+
636
+ const requestNode = this._getRequestNode(nodes)
637
+ if (!requestNode) throw new Error('No nodes available')
533
638
 
534
- const requestNode = this._getRequestNode(nodes);
535
- const formattedQuery = URL_PATTERN.test(query) ?
536
- query :
537
- `${source}${SEARCH_PREFIX}${query}`;
639
+ const formattedQuery = isProbablyUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}`
538
640
 
539
641
  try {
540
- const endpoint = `/v4/loadtracks?identifier=${encodeURIComponent(formattedQuery)}`;
541
- const response = await requestNode.rest.makeRequest('GET', endpoint);
642
+ const endpoint = `/${this.restVersion}/loadtracks?identifier=${encodeURIComponent(formattedQuery)}`
643
+ const response = await requestNode.rest.makeRequest('GET', endpoint)
542
644
 
543
- if (['empty', 'NO_MATCHES'].includes(response.loadType)) {
544
- return EMPTY_TRACKS_RESPONSE;
645
+ if (!response || response.loadType === 'empty' || response.loadType === 'NO_MATCHES') {
646
+ return EMPTY_TRACKS_RESPONSE
545
647
  }
546
648
 
547
- return this._constructResponse(response, requester, requestNode);
649
+ return this._constructResponse(response, requester, requestNode)
548
650
  } catch (error) {
549
- throw new Error(error.name === 'AbortError' ?
550
- 'Request timeout' :
551
- `Resolve failed: ${error.message}`
552
- );
651
+ throw new Error(error?.name === 'AbortError' ? 'Request timeout' : `Resolve failed: ${error?.message || String(error)}`)
553
652
  }
554
653
  }
555
654
 
556
655
  _getRequestNode(nodes) {
557
- if (!nodes) return this.leastUsedNodes[0];
558
- if (nodes instanceof Node) return nodes;
559
- if (typeof nodes === 'string') return this.nodeMap.get(nodes) || this.leastUsedNodes[0];
560
- throw new TypeError(`Invalid nodes parameter: ${typeof nodes}`);
656
+ if (!nodes) {
657
+ const chosen = this._chooseLeastBusyNode(this.leastUsedNodes)
658
+ if (!chosen) throw new Error('No nodes available')
659
+ return chosen
660
+ }
661
+
662
+ if (nodes instanceof Node) return nodes
663
+
664
+ if (Array.isArray(nodes)) {
665
+ const candidates = nodes.filter(n => n?.connected)
666
+ const chosen = this._chooseLeastBusyNode(candidates.length ? candidates : this.leastUsedNodes)
667
+ if (!chosen) throw new Error('No nodes available')
668
+ return chosen
669
+ }
670
+
671
+ if (typeof nodes === 'string') {
672
+ const node = this.nodeMap.get(nodes)
673
+ if (node?.connected) return node
674
+ const chosen = this._chooseLeastBusyNode(this.leastUsedNodes)
675
+ if (!chosen) throw new Error('No nodes available')
676
+ return chosen
677
+ }
678
+
679
+ throw new TypeError(`Invalid nodes parameter: ${typeof nodes}`)
680
+ }
681
+
682
+ _chooseLeastBusyNode(nodes) {
683
+ if (!nodes?.length) return null
684
+ if (nodes.length === 1) return nodes[0]
685
+
686
+ let best = nodes[0]
687
+ let bestScore = this._getCachedNodeLoad(best)
688
+ for (let i = 1; i < nodes.length; i++) {
689
+ const score = this._getCachedNodeLoad(nodes[i])
690
+ if (score < bestScore) {
691
+ bestScore = score
692
+ best = nodes[i]
693
+ }
694
+ }
695
+ return best
561
696
  }
562
697
 
563
698
  _constructResponse(response, requester, requestNode) {
@@ -567,254 +702,297 @@ class Aqua extends EventEmitter {
567
702
  playlistInfo: null,
568
703
  pluginInfo: response.pluginInfo || {},
569
704
  tracks: []
570
- };
705
+ }
571
706
 
572
707
  if (response.loadType === 'error' || response.loadType === 'LOAD_FAILED') {
573
- baseResponse.exception = response.data || response.exception;
574
- return baseResponse;
708
+ baseResponse.exception = response.data || response.exception
709
+ return baseResponse
575
710
  }
576
711
 
577
712
  switch (response.loadType) {
578
- case 'track':
579
- if (response.data) {
580
- baseResponse.tracks.push(new Track(response.data, requester, requestNode));
713
+ case 'track': {
714
+ const data = response.data
715
+ if (data) {
716
+ baseResponse.pluginInfo = data.info?.pluginInfo ?? baseResponse.pluginInfo
717
+ baseResponse.tracks.push(new Track(data, requester, requestNode))
581
718
  }
582
- break;
583
-
719
+ break
720
+ }
584
721
  case 'playlist': {
585
- const info = response.data?.info;
722
+ const info = response.data?.info
586
723
  if (info) {
587
724
  baseResponse.playlistInfo = {
588
725
  name: info.name || info.title,
589
- thumbnail: response.data.pluginInfo?.artworkUrl ||
590
- response.data.tracks?.[0]?.info?.artworkUrl ||
591
- null,
726
+ thumbnail: response.data.pluginInfo?.artworkUrl
727
+ || response.data.tracks?.[0]?.info?.artworkUrl
728
+ || null,
592
729
  ...info
593
- };
730
+ }
594
731
  }
732
+ baseResponse.pluginInfo = response.data?.pluginInfo ?? baseResponse.pluginInfo
595
733
 
596
- const tracks = response.data?.tracks;
597
- if (tracks?.length) {
598
- baseResponse.tracks = tracks.map(t => new Track(t, requester, requestNode));
734
+ if (response.data?.tracks?.length) {
735
+ baseResponse.tracks = response.data.tracks.map(t => new Track(t, requester, requestNode))
599
736
  }
600
- break;
737
+ break
601
738
  }
602
-
603
739
  case 'search': {
604
- const searchData = response.data || EMPTY_ARRAY;
605
- if (searchData.length) {
606
- baseResponse.tracks = searchData.map(t => new Track(t, requester, requestNode));
740
+ if (response.data?.length) {
741
+ baseResponse.tracks = response.data.map(t => new Track(t, requester, requestNode))
607
742
  }
608
- break;
743
+ break
609
744
  }
610
745
  }
611
746
 
612
- return baseResponse;
747
+ return baseResponse
613
748
  }
614
749
 
615
750
  get(guildId) {
616
- const player = this.players.get(guildId);
617
- if (!player) throw new Error(`Player not found: ${guildId}`);
618
- return player;
751
+ const player = this.players.get(guildId)
752
+ if (!player) throw new Error(`Player not found: ${guildId}`)
753
+ return player
619
754
  }
620
755
 
621
756
  async search(query, requester, source = this.defaultSearchPlatform) {
622
- if (!query || !requester) return null;
757
+ if (!query || !requester) return null
623
758
  try {
624
- const { tracks } = await this.resolve({ query, source, requester });
625
- return tracks || null;
759
+ const { tracks } = await this.resolve({ query, source, requester })
760
+ return tracks || null
626
761
  } catch {
627
- return null;
762
+ return null
628
763
  }
629
764
  }
630
765
 
631
766
  async loadPlayers(filePath = './AquaPlayers.jsonl') {
767
+ const lockFile = `${filePath}.lock`
632
768
  try {
633
- await fs.access(filePath);
634
- await this._waitForFirstNode();
635
- const rawData = await fs.readFile(filePath, 'utf8');
636
- const lines = rawData.trim().split('\n').filter(Boolean);
769
+ await fs.promises.access(filePath).catch(() => null)
770
+ await fs.promises.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }).catch(() => null)
771
+
772
+ await this._waitForFirstNode()
637
773
 
638
- for (let i = 0; i < lines.length; i += PLAYER_BATCH_SIZE) {
639
- const batch = lines.slice(i, i + PLAYER_BATCH_SIZE)
640
- .map(line => JSON.parse(line));
774
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' })
775
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
641
776
 
642
- await Promise.all(batch.map(p => this._restorePlayer(p)));
777
+ const batch = []
778
+ for await (const line of rl) {
779
+ if (!line.trim()) continue
780
+ try { batch.push(JSON.parse(line)) } catch { continue }
781
+ if (batch.length >= PLAYER_BATCH_SIZE) {
782
+ await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
783
+ batch.length = 0
784
+ }
785
+ }
786
+ if (batch.length) {
787
+ await Promise.allSettled(batch.map(p => this._restorePlayer(p)))
643
788
  }
644
789
 
645
- await fs.writeFile(filePath, '', 'utf8');
646
- } catch { }
790
+ await fs.promises.writeFile(filePath, '')
791
+ } catch (error) {
792
+ this.emit('debug', 'Aqua', `Load players error: ${error?.message || String(error)}`)
793
+ } finally {
794
+ await fs.promises.unlink(lockFile).catch(() => {})
795
+ }
647
796
  }
648
797
 
649
798
  async savePlayer(filePath = './AquaPlayers.jsonl') {
650
- const lines = [];
651
-
652
- for (const player of this.players.values()) {
653
- const requester = player.requester || player.current?.requester;
654
-
655
- const compactData = {
656
- g: player.guildId,
657
- t: player.textChannel,
658
- v: player.voiceChannel,
659
- u: player.current?.uri || null,
660
- p: player.position || 0,
661
- ts: player.timestamp || 0,
662
- q: player.queue?.tracks?.slice(0, 5).map(tr => tr.uri) || EMPTY_ARRAY,
663
- r: requester ? `${requester.id}:${requester.username}` : null,
664
- vol: player.volume,
665
- pa: player.paused,
666
- pl: player.playing,
667
- nw: player.nowPlayingMessage?.id || null
668
- };
669
- lines.push(JSON.stringify(compactData));
670
- }
671
-
672
- await fs.writeFile(filePath, lines.join('\n'), 'utf8');
673
- this.emit('debug', 'Aqua', `Saved ${lines.length} players to ${filePath}`);
674
- }
675
-
676
- async _restorePlayer(p) {
677
- try {
678
- let player = this.players.get(p.g);
679
- if (!player) {
680
- const targetNode = this.leastUsedNodes[0];
681
- if (!targetNode) return;
682
-
683
- player = await this.createConnection({
684
- guildId: p.g,
685
- textChannel: p.t,
686
- voiceChannel: p.v,
687
- defaultVolume: p.vol || 65,
688
- deaf: true
689
- });
690
- }
691
-
692
- if (p.u && player) {
693
- const resolved = await this.resolve({
694
- query: p.u,
695
- requester: this._parseRequester(p.r)
696
- });
697
- if (resolved.tracks?.[0]) {
698
- player.queue.add(resolved.tracks[0]);
699
- player.position = p.p || 0;
700
- player.paused = !!p.pa;
799
+ const lockFile = `${filePath}.lock`
800
+ try {
801
+ await fs.promises.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }).catch(() => null)
802
+
803
+ const ws = fs.createWriteStream(filePath, { encoding: 'utf8', flags: 'w' })
804
+ const buffer = []
805
+ let count = 0
806
+
807
+ for (const player of this.players.values()) {
808
+ const requester = player.requester || player.current?.requester
809
+ const data = {
810
+ g: player.guildId,
811
+ t: player.textChannel,
812
+ v: player.voiceChannel,
813
+ u: player.current?.uri || null,
814
+ p: player.position || 0,
815
+ ts: player.timestamp || 0,
816
+ q: player.queue?.tracks?.slice(0, 10).map(tr => tr.uri) || [],
817
+ r: requester ? JSON.stringify({ id: requester.id, username: requester.username }) : null,
818
+ vol: player.volume,
819
+ pa: player.paused,
820
+ pl: player.playing,
821
+ nw: player.nowPlayingMessage?.id || null
822
+ }
823
+ buffer.push(JSON.stringify(data))
824
+ count++
825
+ if (buffer.length >= 100) {
826
+ ws.write(buffer.join('\n') + '\n')
827
+ buffer.length = 0
828
+ }
701
829
  }
830
+
831
+ if (buffer.length) ws.write(buffer.join('\n') + '\n')
832
+ await new Promise(resolve => ws.end(resolve))
833
+ this.emit('debug', 'Aqua', `Saved ${count} players to ${filePath}`)
834
+ } catch (error) {
835
+ this.emit('error', null, new Error(`Save players failed: ${error?.message || String(error)}`))
836
+ } finally {
837
+ await fs.promises.unlink(lockFile).catch(() => {})
702
838
  }
839
+ }
703
840
 
704
- if (p.nw && player) {
705
- let message = this.client.cache.messages.get(p.nw);
706
- if (!message) {
707
- const channel = this.client.cache.channels.get(p.t);
708
- if (channel) {
709
- try {
710
- if (channel.client.messages) {
711
- message = await channel.client.messages.fetch(p.nw, channel.id).catch(() => null);
712
- } else {
713
- message = await this.client.messages.fetch(channel.id, p.nw).catch(() => null);
714
- }
715
- } catch (error) {
716
- this.emit('debug', 'Aqua', `Failed to fetch nowPlayingMessage ${p.nw} for guild ${p.g}: ${error.message}`);
717
- }
718
- }
841
+ async _restorePlayer(p) {
842
+ try {
843
+ let player = this.players.get(p.g)
844
+ if (!player) {
845
+ const targetNode = this._chooseLeastBusyNode(this.leastUsedNodes)
846
+ if (!targetNode) return
847
+ player = this.createPlayer(targetNode, {
848
+ guildId: p.g,
849
+ textChannel: p.t,
850
+ voiceChannel: p.v,
851
+ defaultVolume: p.vol || 65,
852
+ deaf: true
853
+ })
719
854
  }
720
- player.nowPlayingMessage = message || null;
721
- }
722
855
 
723
- if (p.q?.length && player) {
724
- const tracks = await Promise.all(
725
- p.q.filter(uri => uri !== p.u).map(uri => this.resolve({ query: uri, requester: p.r }))
726
- ).then(resolved => resolved.flatMap(r => r.tracks));
856
+ const requester = this._parseRequester(p.r)
857
+ const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0, 20)
858
+
859
+ const resolved = await Promise.all(tracksToResolve.map(uri =>
860
+ this.resolve({ query: uri, requester }).catch(() => null)
861
+ ))
862
+ const validTracks = resolved.filter(r => r?.tracks?.length).flatMap(r => r.tracks)
727
863
 
728
- for (const track of tracks) {
729
- player.queue.add(track);
864
+ if (validTracks.length && player.queue?.add) {
865
+ if (player.queue.tracks?.length <= 2) player.queue.tracks = []
866
+ player.queue.add(...validTracks)
730
867
  }
731
- }
732
868
 
733
- if (player) {
734
- if (typeof p.vol === 'number') player.volume = p.vol;
735
- player.paused = !!p.pa;
736
- if (p.u && player.queue.size > 0) {
737
- player.play();
738
- player.seek(p.p || 0);
869
+ if (p.u && validTracks[0]) {
870
+ if (p.vol != null) {
871
+ if (typeof player.setVolume === 'function') {
872
+ await player.setVolume(p.vol)
873
+ } else {
874
+ player.volume = p.vol
875
+ }
876
+ }
877
+
878
+ await player.play()
879
+ if (p.p > 0) setTimeout(() => player.seek?.(p.p), SEEK_DELAY)
880
+ if (p.pa) await player.pause(true)
739
881
  }
740
- }
741
882
 
742
- this.emit("playerRestore", player);
743
- } catch (error) {
744
- this.emit('debug', 'Aqua', `Error restoring player for guild ${p.g}: ${error.message}`);
883
+ if (p.nw && p.t) {
884
+ const channel = this.client.channels?.cache?.get(p.t)
885
+ if (channel?.messages) {
886
+ try {
887
+ player.nowPlayingMessage = await channel.messages.fetch(p.nw).catch(() => null)
888
+ } catch {}
889
+ }
890
+ }
891
+ } catch (error) {
892
+ this.emit('debug', 'Aqua', `Error restoring player for guild ${p.g}: ${error?.message || String(error)}`)
893
+ }
745
894
  }
746
- }
747
895
 
748
896
  _parseRequester(requesterString) {
749
- if (!requesterString) return null;
750
- const match = REQUESTER_PATTERN.exec(requesterString);
751
- return match ? { id: match[1], username: match[2] } : null;
897
+ if (!requesterString || typeof requesterString !== 'string') return null
898
+ try {
899
+ return JSON.parse(requesterString)
900
+ } catch {
901
+ const i = requesterString.indexOf(':')
902
+ if (i <= 0) return null
903
+ return { id: requesterString.substring(0, i), username: requesterString.substring(i + 1) }
904
+ }
752
905
  }
753
906
 
754
- async _waitForFirstNode() {
755
- if (this.leastUsedNodes.length) return;
756
-
757
- return new Promise(resolve => {
758
- const checkInterval = setInterval(() => {
907
+ async _waitForFirstNode(timeout = NODE_TIMEOUT) {
908
+ if (this.leastUsedNodes.length) return
909
+ return new Promise((resolve, reject) => {
910
+ const onReady = () => {
759
911
  if (this.leastUsedNodes.length) {
760
- clearInterval(checkInterval);
761
- resolve();
912
+ clearTimeout(timer)
913
+ this.off('nodeConnect', onReady)
914
+ this.off('nodeCreate', onReady)
915
+ resolve()
762
916
  }
763
- }, NODE_CHECK_INTERVAL);
764
- });
917
+ }
918
+ const timer = setTimeout(() => {
919
+ this.off('nodeConnect', onReady)
920
+ this.off('nodeCreate', onReady)
921
+ reject(new Error('Timeout waiting for first node'))
922
+ }, timeout)
923
+
924
+ this.on('nodeConnect', onReady)
925
+ this.on('nodeCreate', onReady)
926
+ onReady()
927
+ })
765
928
  }
766
929
 
767
930
  _performCleanup() {
768
- const now = Date.now();
931
+ const now = Date.now()
932
+ const expiredGuilds = []
769
933
 
770
934
  for (const [guildId, state] of this._brokenPlayers) {
771
- if (now - state.brokenAt > BROKEN_PLAYER_TTL) {
772
- this._brokenPlayers.delete(guildId);
773
- }
935
+ if (now - state.brokenAt > BROKEN_PLAYER_TTL) expiredGuilds.push(guildId)
774
936
  }
937
+ for (const g of expiredGuilds) this._brokenPlayers.delete(g)
775
938
 
776
- for (const [nodeId, timestamp] of this._lastFailoverAttempt) {
777
- if (now - timestamp > FAILOVER_CLEANUP_TTL) {
778
- this._lastFailoverAttempt.delete(nodeId);
779
- this._failoverQueue.delete(nodeId);
780
- }
939
+ const expiredNodes = []
940
+ for (const [nodeId, ts] of this._lastFailoverAttempt) {
941
+ if (now - ts > FAILOVER_CLEANUP_TTL) expiredNodes.push(nodeId)
942
+ }
943
+ for (const n of expiredNodes) {
944
+ this._lastFailoverAttempt.delete(n)
945
+ this._failoverQueue.delete(n)
781
946
  }
782
947
 
783
- this._invalidateCache();
948
+ if (this._nodeLoadCache.size > 50) {
949
+ this._nodeLoadCache.clear()
950
+ this._nodeLoadCacheTime.clear()
951
+ }
784
952
  }
785
953
 
786
954
  _getAvailableNodes(excludeNode) {
787
- const available = [];
955
+ const out = []
788
956
  for (const node of this.nodeMap.values()) {
789
- if (node !== excludeNode && node.connected) {
790
- available.push(node);
791
- }
957
+ if (node !== excludeNode && node.connected) out.push(node)
792
958
  }
793
- return available;
959
+ return out
794
960
  }
795
961
 
796
962
  destroy() {
797
963
  if (this._cleanupTimer) {
798
- clearInterval(this._cleanupTimer);
799
- this._cleanupTimer = null;
964
+ clearInterval(this._cleanupTimer)
965
+ this._cleanupTimer = null
800
966
  }
801
967
 
802
- for (const player of this.players.values()) {
803
- player.destroy();
968
+ if (this._onNodeConnect) {
969
+ this.off('nodeConnect', this._onNodeConnect)
970
+ this.off('nodeDisconnect', this._onNodeDisconnect)
804
971
  }
805
972
 
973
+ const tasks = []
974
+
975
+ for (const player of this.players.values()) {
976
+ player.removeAllListeners?.()
977
+ tasks.push(Promise.resolve(player.destroy?.()).catch(() => {}))
978
+ }
806
979
  for (const node of this.nodeMap.values()) {
807
- node.removeAllListeners();
980
+ tasks.push(Promise.resolve(node.destroy?.()).catch(() => {}))
808
981
  }
809
982
 
810
- this.removeAllListeners();
811
- this.players.clear();
812
- this.nodeMap.clear();
813
- this._nodeStates.clear();
814
- this._failoverQueue.clear();
815
- this._lastFailoverAttempt.clear();
816
- this._brokenPlayers.clear();
983
+ this.players.clear()
984
+ this.nodeMap.clear()
985
+ this._nodeStates.clear()
986
+ this._failoverQueue.clear()
987
+ this._lastFailoverAttempt.clear()
988
+ this._brokenPlayers.clear()
989
+ this._nodeLoadCache.clear()
990
+ this._nodeLoadCacheTime.clear()
991
+ this._leastUsedNodesCache = null
992
+
993
+ this.removeAllListeners()
994
+ return Promise.all(tasks)
817
995
  }
818
996
  }
819
997
 
820
- module.exports = Aqua;
998
+ module.exports = Aqua