aqualink 2.6.4 → 2.7.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.
@@ -14,9 +14,19 @@ const DEFAULT_OPTIONS = Object.freeze({
14
14
  restVersion: 'v4',
15
15
  plugins: [],
16
16
  autoResume: false,
17
- infiniteReconnects: false
17
+ infiniteReconnects: false,
18
+ failoverOptions: {
19
+ enabled: true,
20
+ maxRetries: 3,
21
+ retryDelay: 1000,
22
+ preservePosition: true,
23
+ resumePlayback: true,
24
+ cooldownTime: 5000,
25
+ maxFailoverAttempts: 5
26
+ }
18
27
  });
19
- const LEAST_USED_CACHE_TTL = 50;
28
+
29
+ const LEAST_USED_CACHE_TTL = 30;
20
30
 
21
31
  class Aqua extends EventEmitter {
22
32
  constructor(client, nodes, options = {}) {
@@ -34,35 +44,37 @@ class Aqua extends EventEmitter {
34
44
  this.initiated = false;
35
45
  this.version = pkgVersion;
36
46
 
37
- this.options = Object.assign({}, DEFAULT_OPTIONS, options);
38
-
39
- const {
40
- shouldDeleteMessage,
41
- defaultSearchPlatform,
42
- leaveOnEnd,
43
- restVersion,
44
- plugins,
45
- autoResume,
46
- infiniteReconnects,
47
- send
48
- } = this.options;
49
-
50
- this.shouldDeleteMessage = shouldDeleteMessage;
51
- this.defaultSearchPlatform = defaultSearchPlatform;
52
- this.leaveOnEnd = leaveOnEnd;
53
- this.restVersion = restVersion;
54
- this.plugins = plugins;
55
- this.autoResume = autoResume;
56
- this.infiniteReconnects = infiniteReconnects;
57
-
58
- this.send = send || this.defaultSendFunction.bind(this);
47
+ this.options = { ...DEFAULT_OPTIONS, ...options };
48
+ this.failoverOptions = { ...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions };
49
+
50
+ this.shouldDeleteMessage = this.options.shouldDeleteMessage;
51
+ this.defaultSearchPlatform = this.options.defaultSearchPlatform;
52
+ this.leaveOnEnd = this.options.leaveOnEnd;
53
+ this.restVersion = this.options.restVersion;
54
+ this.plugins = this.options.plugins;
55
+ this.autoResume = this.options.autoResume;
56
+ this.infiniteReconnects = this.options.infiniteReconnects;
57
+ this.send = this.options.send || this.defaultSendFunction.bind(this);
59
58
 
60
59
  this._leastUsedCache = { nodes: [], timestamp: 0 };
60
+
61
+ this._nodeStates = new Map();
62
+ this._failoverQueue = new Map();
63
+ this._lastFailoverAttempt = new Map();
64
+
65
+ this._boundCleanupPlayer = this.cleanupPlayer.bind(this);
66
+ this._boundHandlePlayerDestroy = this._handlePlayerDestroy.bind(this);
61
67
  }
62
68
 
63
- defaultSendFunction(payload) {
64
- const guild = this.client.guilds.cache.get(payload.d.guild_id);
65
- if (guild) guild.shard.send(payload);
69
+ defaultSendFunction(packet) {
70
+ const guild = this.client?.cache?.guilds.get(packet.d.guild_id) ?? this.client.guilds.cache.get(packet.d.guild_id);
71
+ if (guild) {
72
+ if (this.client.gateway) {
73
+ this.client.gateway.send(this.client.gateway.calculateShardId(packet.d.guild_id), packet);
74
+ } else {
75
+ guild.shard.send(packet);
76
+ }
77
+ }
66
78
  }
67
79
 
68
80
  get leastUsedNodes() {
@@ -71,12 +83,9 @@ class Aqua extends EventEmitter {
71
83
  return this._leastUsedCache.nodes;
72
84
  }
73
85
 
74
- const connectedNodes = [];
75
- for (const node of this.nodeMap.values()) {
76
- if (node.connected) connectedNodes.push(node);
77
- }
78
-
79
- connectedNodes.sort((a, b) => a.rest.calls - b.rest.calls);
86
+ const connectedNodes = Array.from(this.nodeMap.values())
87
+ .filter(node => node.connected)
88
+ .sort((a, b) => a.rest.calls - b.rest.calls);
80
89
 
81
90
  this._leastUsedCache = { nodes: connectedNodes, timestamp: now };
82
91
  return connectedNodes;
@@ -87,15 +96,23 @@ class Aqua extends EventEmitter {
87
96
  this.clientId = clientId;
88
97
 
89
98
  try {
90
- const nodePromises = [];
91
- for (const node of this.nodes) {
92
- nodePromises.push(this.createNode(node));
99
+ const nodePromises = this.nodes.map(node => this.createNode(node).catch(err => {
100
+ console.error(`Failed to create node ${node.name || node.host}:`, err);
101
+ return null;
102
+ }));
103
+
104
+ const results = await Promise.allSettled(nodePromises);
105
+ const successfulNodes = results.filter(r => r.status === 'fulfilled' && r.value).length;
106
+
107
+ if (successfulNodes === 0) {
108
+ throw new Error("No nodes could be connected");
93
109
  }
94
- await Promise.all(nodePromises);
95
110
 
96
- for (const plugin of this.plugins) {
97
- plugin.load(this);
98
- }
111
+ await Promise.all(this.plugins.map(plugin =>
112
+ Promise.resolve(plugin.load(this)).catch(err =>
113
+ console.error("Plugin load error:", err)
114
+ )
115
+ ));
99
116
 
100
117
  this.initiated = true;
101
118
  } catch (error) {
@@ -112,15 +129,17 @@ class Aqua extends EventEmitter {
112
129
 
113
130
  const node = new Node(this, options, this.options);
114
131
  this.nodeMap.set(nodeId, node);
115
- this._leastUsedCache.timestamp = 0;
132
+ this._invalidateCache();
133
+
134
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
116
135
 
117
136
  try {
118
137
  await node.connect();
138
+ this._nodeStates.set(nodeId, { connected: true, failoverInProgress: false });
119
139
  this.emit("nodeCreate", node);
120
140
  return node;
121
141
  } catch (error) {
122
- this.nodeMap.delete(nodeId);
123
- console.error("Failed to connect node:", error);
142
+ this._cleanupNode(nodeId);
124
143
  throw error;
125
144
  }
126
145
  }
@@ -129,12 +148,263 @@ class Aqua extends EventEmitter {
129
148
  const node = this.nodeMap.get(identifier);
130
149
  if (!node) return;
131
150
 
132
- node.destroy();
133
- this.nodeMap.delete(identifier);
134
- this._leastUsedCache.timestamp = 0;
151
+ this._cleanupNode(identifier);
135
152
  this.emit("nodeDestroy", node);
136
153
  }
137
154
 
155
+ _cleanupNode(nodeId) {
156
+ this.nodeMap.delete(nodeId);
157
+ this._nodeStates.delete(nodeId);
158
+ this._failoverQueue.delete(nodeId);
159
+ this._lastFailoverAttempt.delete(nodeId);
160
+ this._invalidateCache();
161
+ }
162
+
163
+ _invalidateCache() {
164
+ this._leastUsedCache.timestamp = 0;
165
+ }
166
+
167
+ async handleNodeFailover(failedNode) {
168
+ if (!this.failoverOptions.enabled) return;
169
+
170
+ const nodeId = failedNode.name || failedNode.host;
171
+ const now = Date.now();
172
+
173
+ const nodeState = this._nodeStates.get(nodeId);
174
+ if (nodeState?.failoverInProgress) return;
175
+
176
+ const lastAttempt = this._lastFailoverAttempt.get(nodeId);
177
+ if (lastAttempt && (now - lastAttempt) < this.failoverOptions.cooldownTime) return;
178
+
179
+ const currentAttempts = this._failoverQueue.get(nodeId) || 0;
180
+ if (currentAttempts >= this.failoverOptions.maxFailoverAttempts) return;
181
+
182
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: true });
183
+ this._lastFailoverAttempt.set(nodeId, now);
184
+ this._failoverQueue.set(nodeId, currentAttempts + 1);
185
+
186
+ try {
187
+ this.emit("nodeFailover", failedNode);
188
+
189
+ const affectedPlayers = this._getPlayersForNode(failedNode);
190
+ if (affectedPlayers.length === 0) {
191
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
192
+ return;
193
+ }
194
+
195
+ const availableNodes = this._getAvailableNodesForFailover(failedNode);
196
+ if (availableNodes.length === 0) {
197
+ this.emit("error", null, new Error("No available nodes for failover"));
198
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
199
+ return;
200
+ }
201
+
202
+ const failoverResults = await this._migratePlayersWithRetry(affectedPlayers, availableNodes);
203
+
204
+ const successful = failoverResults.filter(r => r.success).length;
205
+ const failed = failoverResults.length - successful;
206
+
207
+ if (successful > 0) {
208
+ this.emit("nodeFailoverComplete", failedNode, successful, failed);
209
+ }
210
+
211
+ } catch (error) {
212
+ this.emit("error", null, new Error(`Failover failed for node ${nodeId}: ${error.message}`));
213
+ } finally {
214
+ this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
215
+ }
216
+ }
217
+
218
+ _getPlayersForNode(node) {
219
+ const affectedPlayers = [];
220
+ for (const player of this.players.values()) {
221
+ if (player.nodes === node || player.nodes?.name === node.name) {
222
+ affectedPlayers.push(player);
223
+ }
224
+ }
225
+ return affectedPlayers;
226
+ }
227
+
228
+ _getAvailableNodesForFailover(failedNode) {
229
+ return this.leastUsedNodes.filter(node =>
230
+ node !== failedNode && node.name !== failedNode.name
231
+ );
232
+ }
233
+
234
+ async _migratePlayersWithRetry(players, availableNodes) {
235
+ const results = [];
236
+
237
+ const concurrency = 3;
238
+ for (let i = 0; i < players.length; i += concurrency) {
239
+ const batch = players.slice(i, i + concurrency);
240
+ const batchPromises = batch.map(async player => {
241
+ try {
242
+ const result = await this._migratePlayer(player, availableNodes);
243
+ return { player, success: true, result };
244
+ } catch (error) {
245
+ await this._boundCleanupPlayer(player);
246
+ return { player, success: false, error };
247
+ }
248
+ });
249
+
250
+ const batchResults = await Promise.allSettled(batchPromises);
251
+ results.push(...batchResults.map(r => r.value || r.reason));
252
+ }
253
+
254
+ return results;
255
+ }
256
+
257
+ async _migratePlayer(player, availableNodes) {
258
+ if (!player || !availableNodes.length) {
259
+ throw new Error("Invalid player or no available nodes");
260
+ }
261
+
262
+ const guildId = player.guildId;
263
+ let retryCount = 0;
264
+
265
+ while (retryCount < this.failoverOptions.maxRetries) {
266
+ try {
267
+ const targetNode = this._selectBestNode(availableNodes, player);
268
+ if (!targetNode) throw new Error("No suitable node found");
269
+
270
+ const playerState = this._capturePlayerState(player);
271
+ if (!playerState) throw new Error("Failed to capture player state");
272
+
273
+ const newPlayer = await this._createPlayerOnNode(targetNode, player, playerState);
274
+ if (!newPlayer) throw new Error("Failed to create player on target node");
275
+
276
+ await this._restorePlayerState(newPlayer, playerState);
277
+
278
+ newPlayer.destroy();
279
+ if (playerState.current) {
280
+ newPlayer.queue.add(playerState.current);
281
+ }
282
+
283
+ this.emit("playerMigrated", player, newPlayer, targetNode);
284
+ return newPlayer;
285
+
286
+ } catch (error) {
287
+ retryCount++;
288
+ if (retryCount < this.failoverOptions.maxRetries) {
289
+ await this._delay(this.failoverOptions.retryDelay);
290
+ } else {
291
+ throw error;
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ _selectBestNode(availableNodes, player) {
298
+ if (player.region) {
299
+ const regionNode = availableNodes.find(node =>
300
+ node.regions?.includes(player.region.toLowerCase())
301
+ );
302
+ if (regionNode) return regionNode;
303
+ }
304
+
305
+ return availableNodes[0];
306
+ }
307
+
308
+ _capturePlayerState(player) {
309
+ try {
310
+ return {
311
+ guildId: player.guildId,
312
+ textChannel: player.textChannel,
313
+ voiceChannel: player.voiceChannel,
314
+ volume: player.volume || 100,
315
+ paused: player.paused || false,
316
+ position: player.position || 0,
317
+ current: player.current ? { ...player.current } : null,
318
+ queue: player.queue?.tracks ? [...player.queue.tracks] : [],
319
+ repeat: player.repeat,
320
+ shuffle: player.shuffle,
321
+ deaf: player.deaf || false,
322
+ mute: player.mute || false,
323
+ region: player.region,
324
+ requester: player.requester,
325
+ timestamp: Date.now()
326
+ };
327
+ } catch (error) {
328
+ return null;
329
+ }
330
+ }
331
+
332
+ async _createPlayerOnNode(targetNode, originalPlayer, playerState) {
333
+ const options = {
334
+ guildId: playerState.guildId,
335
+ textChannel: playerState.textChannel,
336
+ voiceChannel: playerState.voiceChannel,
337
+ defaultVolume: playerState.volume || 100,
338
+ deaf: playerState.deaf || false,
339
+ mute: playerState.mute || false,
340
+ region: playerState.region
341
+ };
342
+
343
+ return this.createPlayer(targetNode, options);
344
+ }
345
+
346
+ async _restorePlayerState(newPlayer, playerState) {
347
+ if (!newPlayer || !playerState) return;
348
+
349
+ try {
350
+ // Batch operations where possible
351
+ const operations = [];
352
+
353
+ if (playerState.volume !== undefined) {
354
+ operations.push(newPlayer.setVolume(playerState.volume));
355
+ }
356
+
357
+ // Restore queue efficiently
358
+ if (playerState.queue?.length > 0) {
359
+ newPlayer.queue.add(...playerState.queue);
360
+ }
361
+
362
+ // Wait for all operations
363
+ await Promise.all(operations);
364
+
365
+ // Handle current track restoration
366
+ if (playerState.current && this.failoverOptions.preservePosition) {
367
+ newPlayer.queue.unshift(playerState.current);
368
+
369
+ if (this.failoverOptions.resumePlayback) {
370
+ await newPlayer.play();
371
+
372
+ if (playerState.position > 0) {
373
+ await this._delay(300); // Reduced delay
374
+ await newPlayer.seek(playerState.position);
375
+ }
376
+
377
+ if (playerState.paused) {
378
+ await newPlayer.pause();
379
+ }
380
+ }
381
+ }
382
+
383
+ // Restore other properties
384
+ Object.assign(newPlayer, {
385
+ repeat: playerState.repeat,
386
+ shuffle: playerState.shuffle
387
+ });
388
+
389
+ } catch (error) {
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ _delay(ms) {
395
+ return new Promise(resolve => setTimeout(resolve, ms));
396
+ }
397
+
398
+ // Optimized cleanup
399
+ async cleanupPlayer(player) {
400
+ if (!player) return;
401
+ try {
402
+ await player.destroy();
403
+ } catch (error) {
404
+ // Silent fail for cleanup
405
+ }
406
+ }
407
+
138
408
  updateVoiceState({ d, t }) {
139
409
  const player = this.players.get(d.guild_id);
140
410
  if (!player) return;
@@ -147,7 +417,7 @@ class Aqua extends EventEmitter {
147
417
  }
148
418
 
149
419
  if (d.channel_id === null) {
150
- this.cleanupPlayer(player);
420
+ this._boundCleanupPlayer(player);
151
421
  }
152
422
  }
153
423
  }
@@ -164,34 +434,33 @@ class Aqua extends EventEmitter {
164
434
  }
165
435
  }
166
436
 
437
+ // Optimized sorting with caching
167
438
  const loadCache = new Map();
168
439
  regionNodes.sort((a, b) => {
169
- if (!loadCache.has(a)) loadCache.set(a, this.calculateLoad(a));
170
- if (!loadCache.has(b)) loadCache.set(b, this.calculateLoad(b));
171
- return loadCache.get(a) - loadCache.get(b);
440
+ const loadA = loadCache.get(a) ?? (loadCache.set(a, this._calculateLoad(a)), loadCache.get(a));
441
+ const loadB = loadCache.get(b) ?? (loadCache.set(b, this._calculateLoad(b)), loadCache.get(b));
442
+ return loadA - loadB;
172
443
  });
173
444
 
174
445
  return regionNodes;
175
446
  }
176
447
 
177
- calculateLoad(node) {
448
+ _calculateLoad(node) {
178
449
  const stats = node?.stats?.cpu;
179
450
  if (!stats) return 0;
180
- const { systemLoad, cores } = stats;
181
- return (systemLoad / cores) * 100;
451
+ return (stats.systemLoad / stats.cores) * 100;
182
452
  }
183
453
 
184
454
  createConnection(options) {
185
455
  if (!this.initiated) throw new Error("Aqua must be initialized before this operation");
186
456
 
187
457
  const existingPlayer = this.players.get(options.guildId);
188
- if (existingPlayer && existingPlayer.voiceChannel) return existingPlayer;
458
+ if (existingPlayer?.voiceChannel) return existingPlayer;
189
459
 
190
460
  const availableNodes = options.region ? this.fetchRegion(options.region) : this.leastUsedNodes;
191
- const node = availableNodes[0];
192
- if (!node) throw new Error("No nodes are available");
461
+ if (!availableNodes.length) throw new Error("No nodes are available");
193
462
 
194
- return this.createPlayer(node, options);
463
+ return this.createPlayer(availableNodes[0], options);
195
464
  }
196
465
 
197
466
  createPlayer(node, options) {
@@ -200,16 +469,18 @@ class Aqua extends EventEmitter {
200
469
  const player = new Player(this, node, options);
201
470
  this.players.set(options.guildId, player);
202
471
 
203
- player.on("destroy", () => {
204
- this.players.delete(options.guildId);
205
- this.emit("playerDestroy", player);
206
- });
207
-
472
+ // Use pre-bound method for better performance
473
+ player.once("destroy", this._boundHandlePlayerDestroy);
208
474
  player.connect(options);
209
475
  this.emit("playerCreate", player);
210
476
  return player;
211
477
  }
212
478
 
479
+ _handlePlayerDestroy(player) {
480
+ this.players.delete(player.guildId);
481
+ this.emit("playerDestroy", player);
482
+ }
483
+
213
484
  async destroyPlayer(guildId) {
214
485
  const player = this.players.get(guildId);
215
486
  if (!player) return;
@@ -220,14 +491,14 @@ class Aqua extends EventEmitter {
220
491
  this.players.delete(guildId);
221
492
  this.emit("playerDestroy", player);
222
493
  } catch (error) {
223
- console.error(`Error destroying player for guild ${guildId}:`, error);
494
+ // Silent fail for cleanup
224
495
  }
225
496
  }
226
497
 
227
498
  async resolve({ query, source = this.defaultSearchPlatform, requester, nodes }) {
228
499
  if (!this.initiated) throw new Error("Aqua must be initialized before this operation");
229
500
 
230
- const requestNode = this.getRequestNode(nodes);
501
+ const requestNode = this._getRequestNode(nodes);
231
502
  const formattedQuery = URL_REGEX.test(query) ? query : `${source}:${query}`;
232
503
 
233
504
  try {
@@ -235,10 +506,10 @@ class Aqua extends EventEmitter {
235
506
  const response = await requestNode.rest.makeRequest("GET", endpoint);
236
507
 
237
508
  if (["empty", "NO_MATCHES"].includes(response.loadType)) {
238
- return await this.handleNoMatches(query);
509
+ return this._createEmptyResponse();
239
510
  }
240
511
 
241
- return this.constructResponse(response, requester, requestNode);
512
+ return this._constructResponse(response, requester, requestNode);
242
513
  } catch (error) {
243
514
  if (error.name === "AbortError") {
244
515
  throw new Error("Request timed out");
@@ -247,19 +518,16 @@ class Aqua extends EventEmitter {
247
518
  }
248
519
  }
249
520
 
250
- getRequestNode(nodes) {
521
+ _getRequestNode(nodes) {
251
522
  if (!nodes) return this.leastUsedNodes[0];
252
-
253
523
  if (nodes instanceof Node) return nodes;
254
524
  if (typeof nodes === "string") {
255
- const mappedNode = this.nodeMap.get(nodes);
256
- return mappedNode || this.leastUsedNodes[0];
525
+ return this.nodeMap.get(nodes) || this.leastUsedNodes[0];
257
526
  }
258
-
259
527
  throw new TypeError(`'nodes' must be a string or Node instance, received: ${typeof nodes}`);
260
528
  }
261
529
 
262
- async handleNoMatches(query) {
530
+ _createEmptyResponse() {
263
531
  return {
264
532
  loadType: "empty",
265
533
  exception: null,
@@ -269,7 +537,7 @@ class Aqua extends EventEmitter {
269
537
  };
270
538
  }
271
539
 
272
- async constructResponse(response, requester, requestNode) {
540
+ _constructResponse(response, requester, requestNode) {
273
541
  const baseResponse = {
274
542
  loadType: response.loadType,
275
543
  exception: null,
@@ -283,44 +551,34 @@ class Aqua extends EventEmitter {
283
551
  return baseResponse;
284
552
  }
285
553
 
286
- const trackFactory = (trackData) => new Track(trackData, requester, requestNode);
287
-
288
554
  switch (response.loadType) {
289
555
  case "track":
290
556
  if (response.data) {
291
- baseResponse.tracks.push(trackFactory(response.data));
557
+ baseResponse.tracks.push(new Track(response.data, requester, requestNode));
292
558
  }
293
559
  break;
560
+
294
561
  case "playlist": {
295
562
  const info = response.data?.info;
296
563
  if (info) {
297
- const playlistInfo = {
564
+ baseResponse.playlistInfo = {
298
565
  name: info.name ?? info.title,
299
566
  thumbnail: response.data.pluginInfo?.artworkUrl ?? (response.data.tracks?.[0]?.info?.artworkUrl || null),
300
567
  ...info
301
568
  };
302
- baseResponse.playlistInfo = playlistInfo;
303
569
  }
304
570
 
305
571
  const tracks = response.data?.tracks;
306
572
  if (tracks?.length) {
307
- const trackCount = tracks.length;
308
- baseResponse.tracks = new Array(trackCount);
309
- for (let i = 0; i < trackCount; i++) {
310
- baseResponse.tracks[i] = trackFactory(tracks[i]);
311
- }
573
+ baseResponse.tracks = tracks.map(track => new Track(track, requester, requestNode));
312
574
  }
313
575
  break;
314
576
  }
315
577
 
316
578
  case "search": {
317
579
  const searchData = response.data ?? [];
318
- const dataLength = searchData.length;
319
- if (dataLength) {
320
- baseResponse.tracks = new Array(dataLength);
321
- for (let i = 0; i < dataLength; i++) {
322
- baseResponse.tracks[i] = trackFactory(searchData[i]);
323
- }
580
+ if (searchData.length) {
581
+ baseResponse.tracks = searchData.map(track => new Track(track, requester, requestNode));
324
582
  }
325
583
  break;
326
584
  }
@@ -342,110 +600,163 @@ class Aqua extends EventEmitter {
342
600
  const { tracks } = await this.resolve({ query, source, requester });
343
601
  return tracks || null;
344
602
  } catch (error) {
345
- console.error("Search error:", error);
346
603
  return null;
347
604
  }
348
605
  }
349
606
 
607
+ // Optimized save/load methods
350
608
  async savePlayer(filePath = "./AquaPlayers.json") {
351
- const data = Array.from(this.players.values()).map(player => ({
609
+ const data = Array.from(this.players.values(), player => ({
352
610
  g: player.guildId,
353
611
  t: player.textChannel,
354
612
  v: player.voiceChannel,
355
613
  u: player.current?.uri || null,
356
614
  p: player.position || 0,
357
615
  ts: player.timestamp || 0,
358
- q: player.queue?.tracks?.map(tr => tr.uri).slice(0, 5) || [],
616
+ q: player.queue?.tracks?.slice(0, 5).map(tr => tr.uri) || [],
359
617
  r: player.requester || player.current?.requester,
360
618
  vol: player.volume,
361
- pa: player.paused
619
+ pa: player.paused,
620
+ n: player.nodes?.name || null
362
621
  }));
622
+
363
623
  await fs.writeFile(filePath, JSON.stringify(data), "utf8");
364
- this.emit("debug", "Aqua", `Saved ${data.length} players to ${filePath}`);
365
- }
366
-
367
- async waitForFirstNode() {
368
- if (this.leastUsedNodes.length > 0) return;
369
- return new Promise(resolve => {
370
- const check = () => {
371
- if (this.leastUsedNodes.length > 0) {
372
- resolve();
373
- } else {
374
- setTimeout(check, 100);
375
- }
376
- };
377
- check();
378
- });
379
624
  }
380
625
 
381
626
  async loadPlayers(filePath = "./AquaPlayers.json") {
382
627
  try {
383
- await fs.promises.access(filePath);
384
- } catch {
385
- this.emit("debug", "Aqua", `File ${filePath} does not exist, skipping load.`);
386
- return;
628
+ await fs.access(filePath);
629
+ await this._waitForFirstNode();
630
+
631
+ const data = JSON.parse(await fs.readFile(filePath, "utf8"));
632
+
633
+ // Process in batches to avoid overwhelming
634
+ const batchSize = 5;
635
+ for (let i = 0; i < data.length; i += batchSize) {
636
+ const batch = data.slice(i, i + batchSize);
637
+ await Promise.all(batch.map(p => this._restorePlayer(p)));
638
+ }
639
+
640
+ await fs.writeFile(filePath, "[]", "utf8");
641
+ } catch (error) {
642
+ // Silent fail if file doesn't exist
387
643
  }
644
+ }
645
+
646
+ async _restorePlayer(p) {
388
647
  try {
389
- await this.waitForFirstNode();
390
- const data = JSON.parse(await fs.readFile(filePath, "utf8"));
391
- for (const p of data) {
392
- let player = this.players.get(p.g);
393
- if (!player) {
394
- player = await this.createConnection({
395
- guildId: p.g,
396
- textChannel: p.t,
397
- voiceChannel: p.v,
398
- defaultVolume: p.vol || 65,
399
- deaf: true
400
- });
401
- }
648
+ let player = this.players.get(p.g);
649
+ if (!player) {
650
+ const targetNode = (p.n && this.nodeMap.get(p.n)?.connected) ?
651
+ this.nodeMap.get(p.n) : this.leastUsedNodes[0];
652
+
653
+ if (!targetNode) return;
654
+
655
+ player = await this.createConnection({
656
+ guildId: p.g,
657
+ textChannel: p.t,
658
+ voiceChannel: p.v,
659
+ defaultVolume: p.vol || 65,
660
+ deaf: true
661
+ });
662
+ }
402
663
 
403
- if (p.u && player) {
404
- const resolved = await this.resolve({ query: p.u, requester: p.r });
405
- if (resolved.tracks && resolved.tracks.length > 0) {
406
- player.queue.add(resolved.tracks[0]);
407
- player.position = p.p || 0;
408
- if (typeof p.ts === "number") {
409
- player.timestamp = p.ts;
410
- }
411
- }
664
+ // Restore current track
665
+ if (p.u && player) {
666
+ const resolved = await this.resolve({ query: p.u, requester: p.r });
667
+ if (resolved.tracks?.[0]) {
668
+ player.queue.add(resolved.tracks[0]);
669
+ player.position = p.p || 0;
670
+ if (typeof p.ts === "number") player.timestamp = p.ts;
412
671
  }
413
- if (Array.isArray(p.q) && player) {
414
- for (const uri of p.q) {
415
- if (!p.u || uri !== p.u) {
416
- const resolved = await this.resolve({ query: uri, requester: p.r });
417
- if (resolved.tracks && resolved.tracks.length > 0) {
418
- player.queue.add(resolved.tracks[0]);
419
- player.position = p.p || 0;
420
- if (typeof p.ts === "number") {
421
- player.timestamp = p.ts;
422
- }
423
- }
424
- }
425
- }
426
- }
427
- if (player) {
428
- player.paused = !!p.pa;
429
- if (!player.playing && !player.paused && player.queue.size > 0) {
430
- player.play();
672
+ }
673
+
674
+ // Restore queue
675
+ if (p.q?.length && player) {
676
+ const queuePromises = p.q
677
+ .filter(uri => uri !== p.u)
678
+ .map(uri => this.resolve({ query: uri, requester: p.r }));
679
+
680
+ const queueResults = await Promise.allSettled(queuePromises);
681
+ queueResults.forEach(result => {
682
+ if (result.status === 'fulfilled' && result.value.tracks?.[0]) {
683
+ player.queue.add(result.value.tracks[0]);
431
684
  }
685
+ });
686
+ }
687
+
688
+ if (player) {
689
+ player.paused = !!p.pa;
690
+ if (!player.playing && !player.paused && player.queue.size > 0) {
691
+ player.play();
432
692
  }
433
693
  }
434
- await fs.writeFile(filePath, "[]", "utf8");
435
- this.emit("debug", "Aqua", `Loaded players from ${filePath} and cleared its content.`);
436
694
  } catch (error) {
437
- console.error(`Failed to load players from ${filePath}:`, error);
695
+ // Silent fail for individual player restoration
438
696
  }
439
697
  }
440
698
 
441
- async cleanupPlayer(player) {
442
- if (!player) return;
699
+ async _waitForFirstNode() {
700
+ if (this.leastUsedNodes.length > 0) return;
701
+
702
+ return new Promise(resolve => {
703
+ const checkInterval = setInterval(() => {
704
+ if (this.leastUsedNodes.length > 0) {
705
+ clearInterval(checkInterval);
706
+ resolve();
707
+ }
708
+ }, 100);
709
+ });
710
+ }
443
711
 
444
- try {
445
- await player.destroy();
446
- } catch (error) {
447
- console.error(`Error during player cleanup: ${error.message}`);
712
+ // Utility methods
713
+ resetFailoverAttempts(nodeId) {
714
+ this._failoverQueue.delete(nodeId);
715
+ this._lastFailoverAttempt.delete(nodeId);
716
+ const nodeState = this._nodeStates.get(nodeId);
717
+ if (nodeState) nodeState.failoverInProgress = false;
718
+ }
719
+
720
+ getFailoverStatus() {
721
+ const status = {};
722
+ for (const [nodeId, attempts] of this._failoverQueue) {
723
+ const lastAttempt = this._lastFailoverAttempt.get(nodeId);
724
+ const nodeState = this._nodeStates.get(nodeId);
725
+ status[nodeId] = {
726
+ attempts,
727
+ lastAttempt,
728
+ inProgress: nodeState?.failoverInProgress || false,
729
+ connected: nodeState?.connected || false
730
+ };
731
+ }
732
+ return status;
733
+ }
734
+
735
+ getNodeStats() {
736
+ const stats = {};
737
+ for (const [name, node] of this.nodeMap) {
738
+ stats[name] = {
739
+ connected: node.connected,
740
+ players: node.stats?.players || 0,
741
+ playingPlayers: node.stats?.playingPlayers || 0,
742
+ uptime: node.stats?.uptime || 0,
743
+ cpu: node.stats?.cpu || {},
744
+ memory: node.stats?.memory || {},
745
+ ping: node.stats?.ping || 0
746
+ };
747
+ }
748
+ return stats;
749
+ }
750
+
751
+ async forceFailover(nodeIdentifier) {
752
+ const node = this.nodeMap.get(nodeIdentifier);
753
+ if (!node) return;
754
+
755
+ if (node.connected) {
756
+ await node.destroy();
448
757
  }
758
+
759
+ this._cleanupNode(nodeIdentifier);
449
760
  }
450
761
  }
451
762
 
@@ -51,9 +51,10 @@ class Node {
51
51
  this.ws = null;
52
52
  this.reconnectAttempted = 0;
53
53
  this.reconnectTimeoutId = null;
54
+ this.isDestroyed = false;
55
+ this.lastHealthCheck = Date.now();
54
56
 
55
57
  this._headers = this._constructHeaders();
56
-
57
58
  this.initializeStats();
58
59
  }
59
60
 
@@ -73,7 +74,7 @@ class Node {
73
74
  const headers = {
74
75
  Authorization: this.password,
75
76
  "User-Id": this.aqua.clientId,
76
- "Client-Name": `Aqua/${this.aqua.version} (https://github.com/ToddyTheNoobDud/AquaLink`
77
+ "Client-Name": `Aqua/${this.aqua.version} (https://github.com/ToddyTheNoobDud/AquaLink)`
77
78
  };
78
79
 
79
80
  if (this.sessionId) {
@@ -86,13 +87,13 @@ class Node {
86
87
  async _onOpen() {
87
88
  this.connected = true;
88
89
  this.reconnectAttempted = 0;
90
+ this.lastHealthCheck = Date.now();
89
91
  this.aqua.emit("debug", this.name, "WebSocket connection established");
90
92
 
91
93
  if (this.aqua.bypassChecks?.nodeFetchInfo) return;
92
94
 
93
95
  try {
94
96
  this.info = await this.rest.makeRequest("GET", "/v4/info");
95
-
96
97
  this.aqua.emit("nodeConnected", this);
97
98
 
98
99
  if (this.autoResume && this.sessionId) {
@@ -120,41 +121,37 @@ class Node {
120
121
  const op = payload?.op;
121
122
  if (!op) return;
122
123
 
123
- switch (op) {
124
- case "stats":
125
- this._updateStats(payload);
126
- break;
127
- case "ready":
128
- this._handleReadyOp(payload);
129
- break;
130
- case "LyricsLineEvent": {
131
- const player = payload.guildId ? this.aqua.players.get(payload.guildId) : null;
132
- const track = payload.track || null;
133
- this.aqua.emit("LyricsLineEvent", player, track, payload);
134
- break;
135
- }
136
- case "LyricsFoundEvent": {
137
- const player = payload.guildId ? this.aqua.players.get(payload.guildId) : null;
138
- const track = payload.track || null;
139
- this.aqua.emit("LyricsFoundEvent", player, track, payload);
140
- break;
141
- }
142
- default:
143
- if (payload.guildId) {
144
- const player = this.aqua.players.get(payload.guildId);
145
- if (player) player.emit(op, payload);
146
- }
147
- break;
124
+ this.lastHealthCheck = Date.now();
125
+
126
+ if (op === "stats") {
127
+ this._updateStats(payload);
128
+ return;
129
+ }
130
+ if (op === "ready") {
131
+ this._handleReadyOp(payload);
132
+ return;
133
+ }
134
+
135
+ if (op.startsWith("Lyrics")) {
136
+ const player = payload.guildId ? this.aqua.players.get(payload.guildId) : null;
137
+ const track = payload.track || null;
138
+ this.aqua.emit(op, player, track, payload);
139
+ return;
140
+ }
141
+
142
+ if (payload.guildId) {
143
+ const player = this.aqua.players.get(payload.guildId);
144
+ if (player) player.emit(op, payload);
148
145
  }
149
146
  }
150
147
 
151
148
  _onClose(code, reason) {
152
149
  this.connected = false;
150
+ const reasonStr = reason?.toString() || "No reason provided";
153
151
 
154
- this.aqua.emit("nodeDisconnect", this, {
155
- code,
156
- reason: reason?.toString() || "No reason provided"
157
- });
152
+ this.aqua.emit("nodeDisconnect", this, { code, reason: reasonStr });
153
+
154
+ this.aqua.handleNodeFailover(this);
158
155
 
159
156
  this.scheduleReconnect(code);
160
157
  }
@@ -162,7 +159,7 @@ class Node {
162
159
  scheduleReconnect(code) {
163
160
  this.clearReconnectTimeout();
164
161
 
165
- if (code === Node.WS_CLOSE_NORMAL) {
162
+ if (code === Node.WS_CLOSE_NORMAL || this.isDestroyed) {
166
163
  this.aqua.emit("debug", this.name, "WebSocket closed normally, not reconnecting");
167
164
  return;
168
165
  }
@@ -204,6 +201,8 @@ class Node {
204
201
  }
205
202
 
206
203
  async connect() {
204
+ if (this.isDestroyed) return;
205
+
207
206
  if (this.ws && this.ws.readyState === Node.WS_OPEN) {
208
207
  this.aqua.emit("debug", this.name, "WebSocket already connected");
209
208
  return;
@@ -231,7 +230,7 @@ class Node {
231
230
  try {
232
231
  this.ws.close();
233
232
  } catch (err) {
234
- this.emitError(`Failed to close WebSocket: ${err.message}`);
233
+ this.emitError(`Failed to close WebSocket: ${err.message}`);
235
234
  }
236
235
  }
237
236
 
@@ -239,25 +238,21 @@ class Node {
239
238
  }
240
239
 
241
240
  destroy(clean = false) {
241
+ this.isDestroyed = true;
242
242
  this.clearReconnectTimeout();
243
243
  this.cleanupExistingConnection();
244
244
 
245
245
  if (!clean) {
246
- for (const player of this.aqua.players.values()) {
247
- if (player.nodes === this) {
248
- player.destroy();
249
- }
250
- }
246
+ this.aqua.handleNodeFailover(this);
251
247
  }
252
248
 
253
249
  this.connected = false;
254
- this.aqua.nodes.delete(this.name);
250
+ this.aqua.destroyNode(this.name);
255
251
  this.aqua.emit("nodeDestroy", this);
256
252
  this.info = null;
257
253
  }
258
254
 
259
255
  async getStats() {
260
-
261
256
  if (this.connected && this.stats) {
262
257
  return this.stats;
263
258
  }
@@ -344,13 +339,11 @@ class Node {
344
339
  async resumePlayers() {
345
340
  try {
346
341
  await this.aqua.loadPlayers();
347
-
348
342
  this.aqua.emit("debug", this.name, "Session resumed successfully");
349
343
  } catch (err) {
350
344
  this.emitError(`Failed to resume session: ${err.message}`);
351
345
  }
352
346
  }
353
-
354
347
  emitError(error) {
355
348
  const errorObj = error instanceof Error ? error : new Error(error);
356
349
  console.error(`[Aqua] [${this.name}] Error:`, errorObj);
@@ -18,7 +18,8 @@ const EVENT_HANDLERS = Object.freeze({
18
18
  TrackChangeEvent: "trackChange",
19
19
  WebSocketClosedEvent: "socketClosed",
20
20
  LyricsLineEvent: "lyricsLine",
21
- LyricsFoundEvent: "lyricsFound" // <-- add this line
21
+ LyricsFoundEvent: "lyricsFound" ,
22
+ LyricsNotFoundEvent: "lyricsNotFound"
22
23
  });
23
24
 
24
25
 
@@ -248,37 +249,47 @@ class Player extends EventEmitter {
248
249
  return this;
249
250
  }
250
251
 
251
- destroy() {
252
- if (!this.connected) return this;
252
+ destroy() {
253
+ if (!this.connected) return this;
253
254
 
254
- const voiceChannelId = this.voiceChannel ? this.voiceChannel.id || this.voiceChannel : null;
255
- this._updateBatcher.destroy();
255
+ const voiceChannelId = this.voiceChannel ? this.voiceChannel.id || this.voiceChannel : null;
256
+ this._updateBatcher.destroy();
256
257
 
257
- this.send({ guild_id: this.guildId, channel_id: null });
258
- this._lastVoiceChannel = voiceChannelId;
259
- this.voiceChannel = null;
260
- this.connected = false;
261
- this.send({ guild_id: this.guildId, channel_id: null });
258
+ this.send({ guild_id: this.guildId, channel_id: null });
259
+ this._lastVoiceChannel = voiceChannelId;
260
+ this.voiceChannel = null;
261
+ this.connected = false;
262
+ this.send({ guild_id: this.guildId, channel_id: null });
262
263
 
263
- if (this.nowPlayingMessage) {
264
- this.nowPlayingMessage.delete().catch(() => { });
265
- this.nowPlayingMessage = null;
266
- }
264
+ if (this.nowPlayingMessage) {
265
+ this.nowPlayingMessage.delete().catch(() => { });
266
+ this.nowPlayingMessage = null;
267
+ }
267
268
 
268
- this.isAutoplay = false;
269
- this.aqua.destroyPlayer(this.guildId);
270
- this.nodes.rest.destroyPlayer(this.guildId);
271
- this.previousTracksCount = 0;
272
- this._dataStore.clear();
273
- this.removeAllListeners();
269
+ this.isAutoplay = false;
270
+ this.aqua.destroyPlayer(this.guildId);
271
+
272
+ if (this.nodes?.connected) {
273
+ try {
274
+ this.nodes.rest.destroyPlayer(this.guildId);
275
+ } catch (error) {
276
+ if (!error.message.includes('ECONNREFUSED')) {
277
+ console.error('Error destroying player on node:', error);
278
+ }
279
+ }
280
+ }
281
+
282
+ this.previousTracksCount = 0;
283
+ this._dataStore.clear();
284
+ this.removeAllListeners();
274
285
 
275
- this.queue = null;
276
- this.previousTracks = null;
277
- this.connection = null;
278
- this.filters = null;
286
+ this.queue = null;
287
+ this.previousTracks = null;
288
+ this.connection = null;
289
+ this.filters = null;
279
290
 
280
- return this;
281
- }
291
+ return this;
292
+ }
282
293
 
283
294
  pause(paused) {
284
295
  if (this.paused === paused) return this;
@@ -324,12 +335,16 @@ class Player extends EventEmitter {
324
335
  return this.nodes.rest.unsubscribeLiveLyrics(this.guildId);
325
336
  }
326
337
 
327
- seek(position) {
328
- if (!this.playing) return this;
329
- this.position += position;
330
- this.batchUpdatePlayer({ position: this.position });
331
- return this;
338
+ seek(position) {
339
+ if (!this.playing) return this;
340
+ if (position < 0) position = 0;
341
+ if (this.current?.info?.length && position > this.current.info.length) {
342
+ position = this.current.info.length;
332
343
  }
344
+ this.position = position;
345
+ this.batchUpdatePlayer({ position: this.position });
346
+ return this;
347
+ }
333
348
 
334
349
  stop() {
335
350
  if (!this.playing) return this;
@@ -520,6 +535,10 @@ class Player extends EventEmitter {
520
535
  this.aqua.emit("lyricsFound", player, track, payload);
521
536
  }
522
537
 
538
+ async lyricsNotFound(player, track, payload) {
539
+ this.aqua.emit("lyricsNotFound", player, track, payload);
540
+ }
541
+
523
542
  send(data) {
524
543
  this.aqua.send({ op: 4, d: data });
525
544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aqualink",
3
- "version": "2.6.4",
3
+ "version": "2.7.0",
4
4
  "description": "An Lavalink client, focused in pure performance and features",
5
5
  "main": "build/index.js",
6
6
  "types": "index.d.ts",
@@ -41,7 +41,7 @@
41
41
  "url": "https://github.com/ToddyTheNoobDud/AquaLink"
42
42
  },
43
43
  "dependencies": {
44
- "ws": "^8.18.2",
44
+ "ws": "^8.18.3",
45
45
  "fs-extra": "^11.3.0",
46
46
  "tseep": "^1.3.1"
47
47
  },