lavalink-client 2.4.4 → 2.4.6

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 CHANGED
@@ -808,3 +808,14 @@ if(previousTrack) {
808
808
  - Added the try to play the next track if there is no current track
809
809
  - *There was a problem trying to auto-reconnect on-Disconnect while the queue was empty, which caused the player to get destroyed by that and log the error in console "`There is no Track in the Queue, nor provided in the PlayOptions`"*
810
810
  - *Now you have to handle that case manually if you want to or set autoReconnectOnlyWithTracks to false (default)*
811
+
812
+
813
+ ## **Version 2.4.4 - Version 2.4.6**
814
+ - `player.changeNode()` is fixed and works - thanks to @PandaIN95
815
+ - The code got re-formatted and re-structured, no code-changes are needed to be made, but it's now cleaner & more readable in some areas
816
+ - The same for the testbot Folder(s), also it imports lavalink-client directly, so you can just copy it and move on from it.
817
+ - Some minor Fixess:
818
+ - Autoplay sometimes doesn't get called when previousAutoplay call failed.
819
+ - remove structuredClone so that it works in bun more stable
820
+ - Player Options Validation also allows single property objects
821
+ - Some typos were fixed
@@ -49,6 +49,8 @@ export declare enum DestroyReasons {
49
49
  NodeReconnectFail = "NodeReconnectFail",
50
50
  Disconnected = "Disconnected",
51
51
  PlayerReconnectFail = "PlayerReconnectFail",
52
+ PlayerChangeNodeFail = "PlayerChangeNodeFail",
53
+ PlayerChangeNodeFailNoEligibleNode = "PlayerChangeNodeFailNoEligibleNode",
52
54
  ChannelDeleted = "ChannelDeleted",
53
55
  DisconnectAllNodes = "DisconnectAllNodes",
54
56
  ReconnectAllNodes = "ReconnectAllNodes",
@@ -53,6 +53,8 @@ var DestroyReasons;
53
53
  DestroyReasons["NodeReconnectFail"] = "NodeReconnectFail";
54
54
  DestroyReasons["Disconnected"] = "Disconnected";
55
55
  DestroyReasons["PlayerReconnectFail"] = "PlayerReconnectFail";
56
+ DestroyReasons["PlayerChangeNodeFail"] = "PlayerChangeNodeFail";
57
+ DestroyReasons["PlayerChangeNodeFailNoEligibleNode"] = "PlayerChangeNodeFailNoEligibleNode";
56
58
  DestroyReasons["ChannelDeleted"] = "ChannelDeleted";
57
59
  DestroyReasons["DisconnectAllNodes"] = "DisconnectAllNodes";
58
60
  DestroyReasons["ReconnectAllNodes"] = "ReconnectAllNodes";
@@ -173,14 +173,20 @@ export declare class LavalinkNode {
173
173
  * Destroys the Node-Connection (Websocket) and all player's of the node
174
174
  * @param destroyReason Destroy Reason to use when destroying the players
175
175
  * @param deleteNode wether to delete the nodte from the nodes list too, if false it will emit a disconnect. @default true
176
+ * @param movePlayers whether to movePlayers to different eligible connected node. If false players won't be moved @default false
176
177
  * @returns void
177
178
  *
178
179
  * @example
180
+ * Destroys node and its players
179
181
  * ```ts
180
182
  * player.node.destroy("custom Player Destroy Reason", true);
181
183
  * ```
184
+ * destroys only the node and moves its players to different connected node.
185
+ * ```ts
186
+ * player.node.destroy("custom Player Destroy Reason", true, true);
187
+ * ```
182
188
  */
183
- destroy(destroyReason?: DestroyReasonsType, deleteNode?: boolean): void;
189
+ destroy(destroyReason?: DestroyReasonsType, deleteNode?: boolean, movePlayers?: boolean): void;
184
190
  /**
185
191
  * Disconnects the Node-Connection (Websocket)
186
192
  * @param disconnectReason Disconnect Reason to use when disconnecting Node
@@ -412,32 +412,94 @@ class LavalinkNode {
412
412
  * Destroys the Node-Connection (Websocket) and all player's of the node
413
413
  * @param destroyReason Destroy Reason to use when destroying the players
414
414
  * @param deleteNode wether to delete the nodte from the nodes list too, if false it will emit a disconnect. @default true
415
+ * @param movePlayers whether to movePlayers to different eligible connected node. If false players won't be moved @default false
415
416
  * @returns void
416
417
  *
417
418
  * @example
419
+ * Destroys node and its players
418
420
  * ```ts
419
421
  * player.node.destroy("custom Player Destroy Reason", true);
420
422
  * ```
423
+ * destroys only the node and moves its players to different connected node.
424
+ * ```ts
425
+ * player.node.destroy("custom Player Destroy Reason", true, true);
426
+ * ```
421
427
  */
422
- destroy(destroyReason, deleteNode = true) {
428
+ destroy(destroyReason, deleteNode = true, movePlayers = false) {
423
429
  if (!this.connected)
424
430
  return;
425
431
  const players = this.NodeManager.LavalinkManager.players.filter(p => p.node.id === this.id);
426
- if (players)
427
- players.forEach(p => {
428
- p.destroy(destroyReason || Constants_1.DestroyReasons.NodeDestroy);
432
+ if (players.size) {
433
+ const enableDebugEvents = this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents;
434
+ const handlePlayerOperations = () => {
435
+ if (movePlayers) {
436
+ const nodeToMove = Array.from(this.NodeManager.leastUsedNodes("playingPlayers"))
437
+ .find(n => n.connected && n.options.id !== this.id);
438
+ if (nodeToMove) {
439
+ return Promise.allSettled(Array.from(players.values()).map(player => player.changeNode(nodeToMove.options.id)
440
+ .catch(error => {
441
+ if (enableDebugEvents) {
442
+ console.error(`Node > destroy() Failed to move player ${player.guildId}: ${error.message}`);
443
+ }
444
+ return player.destroy(error.message ?? Constants_1.DestroyReasons.PlayerChangeNodeFail)
445
+ .catch(destroyError => {
446
+ if (enableDebugEvents) {
447
+ console.error(`Node > destroy() Failed to destroy player ${player.guildId} after move failure: ${destroyError.message}`);
448
+ }
449
+ });
450
+ })));
451
+ }
452
+ else {
453
+ return Promise.allSettled(Array.from(players.values()).map(player => player.destroy(Constants_1.DestroyReasons.PlayerChangeNodeFailNoEligibleNode)
454
+ .catch(error => {
455
+ if (enableDebugEvents) {
456
+ console.error(`Node > destroy() Failed to destroy player ${player.guildId}: ${error.message}`);
457
+ }
458
+ })));
459
+ }
460
+ }
461
+ else {
462
+ return Promise.allSettled(Array.from(players.values()).map(player => player.destroy(destroyReason || Constants_1.DestroyReasons.NodeDestroy)
463
+ .catch(error => {
464
+ if (enableDebugEvents) {
465
+ console.error(`Node > destroy() Failed to destroy player ${player.guildId}: ${error.message}`);
466
+ }
467
+ })));
468
+ }
469
+ };
470
+ // Handle all player operations first, then clean up the socket
471
+ handlePlayerOperations().finally(() => {
472
+ this.socket.close(1000, "Node-Destroy");
473
+ this.socket.removeAllListeners();
474
+ this.socket = null;
475
+ this.reconnectAttempts = 1;
476
+ clearTimeout(this.reconnectTimeout);
477
+ if (deleteNode) {
478
+ this.NodeManager.emit("destroy", this, destroyReason);
479
+ this.NodeManager.nodes.delete(this.id);
480
+ clearInterval(this.heartBeatInterval);
481
+ clearTimeout(this.pingTimeout);
482
+ }
483
+ else {
484
+ this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason });
485
+ }
429
486
  });
430
- this.socket.close(1000, "Node-Destroy");
431
- this.socket.removeAllListeners();
432
- this.socket = null;
433
- this.reconnectAttempts = 1;
434
- clearTimeout(this.reconnectTimeout);
435
- if (deleteNode) {
436
- this.NodeManager.emit("destroy", this, destroyReason);
437
- this.NodeManager.nodes.delete(this.id);
438
487
  }
439
- else {
440
- this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason });
488
+ else { // If no players, proceed with socket cleanup immediately
489
+ this.socket.close(1000, "Node-Destroy");
490
+ this.socket.removeAllListeners();
491
+ this.socket = null;
492
+ this.reconnectAttempts = 1;
493
+ clearTimeout(this.reconnectTimeout);
494
+ if (deleteNode) {
495
+ this.NodeManager.emit("destroy", this, destroyReason);
496
+ this.NodeManager.nodes.delete(this.id);
497
+ clearInterval(this.heartBeatInterval);
498
+ clearTimeout(this.pingTimeout);
499
+ }
500
+ else {
501
+ this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason });
502
+ }
441
503
  }
442
504
  return;
443
505
  }
@@ -966,8 +1028,11 @@ class LavalinkNode {
966
1028
  if (code === 1000 && reason === "Node-Disconnect")
967
1029
  return; // manually disconnected and already emitted the event.
968
1030
  this.NodeManager.emit("disconnect", this, { code, reason });
969
- if (code !== 1000 || reason !== "Node-Destroy")
970
- this.reconnect();
1031
+ if (code !== 1000 || reason !== "Node-Destroy") {
1032
+ if (this.NodeManager.nodes.has(this.id)) { // try to reconnect only when the node is still in the nodeManager.nodes list
1033
+ this.reconnect();
1034
+ }
1035
+ }
971
1036
  }
972
1037
  /** @private util function for handling error events from websocket */
973
1038
  error(error) {
@@ -1159,6 +1224,8 @@ class LavalinkNode {
1159
1224
  }
1160
1225
  /** @private util function for handling trackEnd event */
1161
1226
  async trackEnd(player, track, payload) {
1227
+ if (player.get('internal_nodeChanging') === true)
1228
+ return; // Check if nodeChange is in Progress than stop the trackEnd Event from being triggered.
1162
1229
  const trackToUse = track || this.getTrackOfPayload(payload);
1163
1230
  // If a track was forcibly played
1164
1231
  if (payload.reason === "replaced") {
@@ -1374,6 +1441,8 @@ class LavalinkNode {
1374
1441
  }
1375
1442
  /** private util function for handling the queue end event */
1376
1443
  async queueEnd(player, track, payload) {
1444
+ if (player.get('internal_nodeChanging') === true)
1445
+ return; // Check if nodeChange is in Progress than stop the queueEnd Event from being triggered.
1377
1446
  // add previous track to the queue!
1378
1447
  player.queue.current = null;
1379
1448
  player.playing = false;
@@ -83,7 +83,18 @@ export declare class NodeManager extends EventEmitter {
83
83
  /**
84
84
  * Delete a node from the nodeManager and destroy it
85
85
  * @param node The node to delete
86
+ * @param movePlayers whether to movePlayers to different connected node before deletion. @default false
86
87
  * @returns
88
+ *
89
+ * @example
90
+ * Deletes the node
91
+ * ```ts
92
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete");
93
+ * ```
94
+ * Moves players to a different node before deleting
95
+ * ```ts
96
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete", true);
97
+ * ```
87
98
  */
88
- deleteNode(node: LavalinkNodeIdentifier | LavalinkNode): void;
99
+ deleteNode(node: LavalinkNodeIdentifier | LavalinkNode, movePlayers?: boolean): void;
89
100
  }
@@ -196,13 +196,27 @@ class NodeManager extends events_1.EventEmitter {
196
196
  /**
197
197
  * Delete a node from the nodeManager and destroy it
198
198
  * @param node The node to delete
199
+ * @param movePlayers whether to movePlayers to different connected node before deletion. @default false
199
200
  * @returns
201
+ *
202
+ * @example
203
+ * Deletes the node
204
+ * ```ts
205
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete");
206
+ * ```
207
+ * Moves players to a different node before deleting
208
+ * ```ts
209
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete", true);
210
+ * ```
200
211
  */
201
- deleteNode(node) {
212
+ deleteNode(node, movePlayers = false) {
202
213
  const decodeNode = typeof node === "string" ? this.nodes.get(node) : node || this.leastUsedNodes()[0];
203
214
  if (!decodeNode)
204
215
  throw new Error("Node was not found");
205
- decodeNode.destroy(Constants_1.DestroyReasons.NodeDeleted);
216
+ if (movePlayers)
217
+ decodeNode.destroy(Constants_1.DestroyReasons.NodeDeleted, true, true);
218
+ else
219
+ decodeNode.destroy(Constants_1.DestroyReasons.NodeDeleted);
206
220
  this.nodes.delete(decodeNode.id);
207
221
  return;
208
222
  }
@@ -217,8 +217,9 @@ export declare class Player {
217
217
  /**
218
218
  * Move the player on a different Audio-Node
219
219
  * @param newNode New Node / New Node Id
220
+ * @param checkSources If it should check if the sources are supported by the new node
220
221
  */
221
- changeNode(newNode: LavalinkNode | string): Promise<string>;
222
+ changeNode(newNode: LavalinkNode | string, checkSources?: boolean): Promise<string>;
222
223
  /** Converts the Player including Queue to a Json state */
223
224
  toJSON(): PlayerJson;
224
225
  }
@@ -459,11 +459,11 @@ class Player {
459
459
  throw new RangeError("Can't skip more than the queue size");
460
460
  await this.queue.splice(0, skipTo - 1);
461
461
  }
462
- if (!this.playing)
462
+ if (!this.playing && !this.queue.current)
463
463
  return (this.play(), this);
464
464
  const now = performance.now();
465
465
  this.set("internal_skipped", true);
466
- await this.node.updatePlayer({ guildId: this.guildId, playerOptions: { track: { encoded: null } } });
466
+ await this.node.updatePlayer({ guildId: this.guildId, playerOptions: { track: { encoded: null }, paused: false } });
467
467
  this.ping.lavalink = Math.round((performance.now() - now) / 10) / 100;
468
468
  return this;
469
469
  }
@@ -639,11 +639,37 @@ class Player {
639
639
  /**
640
640
  * Move the player on a different Audio-Node
641
641
  * @param newNode New Node / New Node Id
642
+ * @param checkSources If it should check if the sources are supported by the new node
642
643
  */
643
- async changeNode(newNode) {
644
+ async changeNode(newNode, checkSources = true) {
644
645
  const updateNode = typeof newNode === "string" ? this.LavalinkManager.nodeManager.nodes.get(newNode) : newNode;
645
646
  if (!updateNode)
646
647
  throw new Error("Could not find the new Node");
648
+ if (!updateNode.connected)
649
+ throw new Error("The provided Node is not active or disconnected");
650
+ if (this.node.id === updateNode.id)
651
+ throw new Error("Player is already on the provided Node");
652
+ if (this.get("internal_nodeChanging") === true)
653
+ throw new Error("Player is already changing the node please wait");
654
+ if (checkSources) {
655
+ const isDefaultSource = () => {
656
+ try {
657
+ this.LavalinkManager.utils.validateSourceString(updateNode, this.LavalinkManager.options.playerOptions.defaultSearchPlatform);
658
+ return true;
659
+ }
660
+ catch {
661
+ return false;
662
+ }
663
+ };
664
+ if (!isDefaultSource())
665
+ throw new RangeError(`defaultSearchPlatform "${this.LavalinkManager.options.playerOptions.defaultSearchPlatform}" is not supported by the newNode`);
666
+ if (this.queue.current || this.queue.tracks.length) { // Check if all queued track sources are supported by the new node
667
+ const trackSources = new Set([this.queue.current, ...this.queue.tracks].map(track => track.info.sourceName));
668
+ const missingSources = [...trackSources].filter(source => !updateNode.info.sourceManagers.includes(source));
669
+ if (missingSources.length)
670
+ throw new RangeError(`Sources missing for Node ${updateNode.id}: ${missingSources.join(', ')}`);
671
+ }
672
+ }
647
673
  if (this.LavalinkManager.options?.advancedOptions?.enableDebugEvents) {
648
674
  this.LavalinkManager.emit("debug", Constants_1.DebugEvents.PlayerChangeNode, {
649
675
  state: "log",
@@ -653,23 +679,60 @@ class Player {
653
679
  }
654
680
  const data = this.toJSON();
655
681
  const currentTrack = this.queue.current;
656
- await this.node.destroyPlayer(this.guildId);
682
+ const voiceData = this.voice;
683
+ if (!voiceData.endpoint ||
684
+ !voiceData.sessionId ||
685
+ !voiceData.token)
686
+ throw new Error("Voice Data is missing, can't change the node");
687
+ this.set("internal_nodeChanging", true); // This will stop execution of trackEnd or queueEnd event while changing the node
688
+ if (this.node.connected)
689
+ await this.node.destroyPlayer(this.guildId); // destroy the player on the currentNode if it's connected
657
690
  this.node = updateNode;
658
691
  const now = performance.now();
659
- await this.connect();
660
- await this.node.updatePlayer({
661
- guildId: this.guildId,
662
- noReplace: false,
663
- playerOptions: {
664
- position: data.position,
665
- volume: Math.round(Math.max(Math.min(data.volume, 1000), 0)),
666
- paused: data.paused,
667
- filters: { ...data.filters, equalizer: data.equalizer },
668
- track: currentTrack ?? undefined
669
- },
670
- });
671
- this.ping.lavalink = Math.round((performance.now() - now) / 10) / 100;
672
- return this.node.id;
692
+ try {
693
+ await this.connect();
694
+ const endpoint = `/sessions/${this.node.sessionId}/players/${this.guildId}`; //Send the VoiceData to the newly connected node.
695
+ await this.node.request(endpoint, r => {
696
+ r.method = "PATCH";
697
+ r.headers["Content-Type"] = "application/json";
698
+ r.body = JSON.stringify({
699
+ voice: {
700
+ token: voiceData.token,
701
+ endpoint: voiceData.endpoint,
702
+ sessionId: voiceData.sessionId
703
+ }
704
+ });
705
+ });
706
+ if (currentTrack) { // If there is a current track, send it to the new node.
707
+ await this.node.updatePlayer({
708
+ guildId: this.guildId,
709
+ noReplace: false,
710
+ playerOptions: {
711
+ track: currentTrack ?? null,
712
+ position: currentTrack ? data.position : 0,
713
+ volume: data.lavalinkVolume,
714
+ paused: data.paused,
715
+ //filters: { ...data.filters, equalizer: data.equalizer }, Sending filters on nodeChange causes issues (player gets dicsonnected)
716
+ }
717
+ });
718
+ }
719
+ this.ping.lavalink = Math.round((performance.now() - now) / 10) / 100;
720
+ return this.node.id;
721
+ }
722
+ catch (error) {
723
+ if (this.LavalinkManager.options?.advancedOptions?.enableDebugEvents) {
724
+ this.LavalinkManager.emit("debug", Constants_1.DebugEvents.PlayerChangeNode, {
725
+ state: "error",
726
+ error: error,
727
+ message: `Player.changeNode() execution failed`,
728
+ functionLayer: "Player > changeNode()",
729
+ });
730
+ }
731
+ throw new Error(`Failed to change the node: ${error}`);
732
+ }
733
+ finally {
734
+ this.set("internal_nodeChanging", undefined);
735
+ }
673
736
  }
674
737
  /** Converts the Player including Queue to a Json state */
675
738
  toJSON() {
@@ -49,6 +49,8 @@ export declare enum DestroyReasons {
49
49
  NodeReconnectFail = "NodeReconnectFail",
50
50
  Disconnected = "Disconnected",
51
51
  PlayerReconnectFail = "PlayerReconnectFail",
52
+ PlayerChangeNodeFail = "PlayerChangeNodeFail",
53
+ PlayerChangeNodeFailNoEligibleNode = "PlayerChangeNodeFailNoEligibleNode",
52
54
  ChannelDeleted = "ChannelDeleted",
53
55
  DisconnectAllNodes = "DisconnectAllNodes",
54
56
  ReconnectAllNodes = "ReconnectAllNodes",
@@ -50,6 +50,8 @@ export var DestroyReasons;
50
50
  DestroyReasons["NodeReconnectFail"] = "NodeReconnectFail";
51
51
  DestroyReasons["Disconnected"] = "Disconnected";
52
52
  DestroyReasons["PlayerReconnectFail"] = "PlayerReconnectFail";
53
+ DestroyReasons["PlayerChangeNodeFail"] = "PlayerChangeNodeFail";
54
+ DestroyReasons["PlayerChangeNodeFailNoEligibleNode"] = "PlayerChangeNodeFailNoEligibleNode";
53
55
  DestroyReasons["ChannelDeleted"] = "ChannelDeleted";
54
56
  DestroyReasons["DisconnectAllNodes"] = "DisconnectAllNodes";
55
57
  DestroyReasons["ReconnectAllNodes"] = "ReconnectAllNodes";
@@ -173,14 +173,20 @@ export declare class LavalinkNode {
173
173
  * Destroys the Node-Connection (Websocket) and all player's of the node
174
174
  * @param destroyReason Destroy Reason to use when destroying the players
175
175
  * @param deleteNode wether to delete the nodte from the nodes list too, if false it will emit a disconnect. @default true
176
+ * @param movePlayers whether to movePlayers to different eligible connected node. If false players won't be moved @default false
176
177
  * @returns void
177
178
  *
178
179
  * @example
180
+ * Destroys node and its players
179
181
  * ```ts
180
182
  * player.node.destroy("custom Player Destroy Reason", true);
181
183
  * ```
184
+ * destroys only the node and moves its players to different connected node.
185
+ * ```ts
186
+ * player.node.destroy("custom Player Destroy Reason", true, true);
187
+ * ```
182
188
  */
183
- destroy(destroyReason?: DestroyReasonsType, deleteNode?: boolean): void;
189
+ destroy(destroyReason?: DestroyReasonsType, deleteNode?: boolean, movePlayers?: boolean): void;
184
190
  /**
185
191
  * Disconnects the Node-Connection (Websocket)
186
192
  * @param disconnectReason Disconnect Reason to use when disconnecting Node
@@ -408,32 +408,94 @@ export class LavalinkNode {
408
408
  * Destroys the Node-Connection (Websocket) and all player's of the node
409
409
  * @param destroyReason Destroy Reason to use when destroying the players
410
410
  * @param deleteNode wether to delete the nodte from the nodes list too, if false it will emit a disconnect. @default true
411
+ * @param movePlayers whether to movePlayers to different eligible connected node. If false players won't be moved @default false
411
412
  * @returns void
412
413
  *
413
414
  * @example
415
+ * Destroys node and its players
414
416
  * ```ts
415
417
  * player.node.destroy("custom Player Destroy Reason", true);
416
418
  * ```
419
+ * destroys only the node and moves its players to different connected node.
420
+ * ```ts
421
+ * player.node.destroy("custom Player Destroy Reason", true, true);
422
+ * ```
417
423
  */
418
- destroy(destroyReason, deleteNode = true) {
424
+ destroy(destroyReason, deleteNode = true, movePlayers = false) {
419
425
  if (!this.connected)
420
426
  return;
421
427
  const players = this.NodeManager.LavalinkManager.players.filter(p => p.node.id === this.id);
422
- if (players)
423
- players.forEach(p => {
424
- p.destroy(destroyReason || DestroyReasons.NodeDestroy);
428
+ if (players.size) {
429
+ const enableDebugEvents = this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents;
430
+ const handlePlayerOperations = () => {
431
+ if (movePlayers) {
432
+ const nodeToMove = Array.from(this.NodeManager.leastUsedNodes("playingPlayers"))
433
+ .find(n => n.connected && n.options.id !== this.id);
434
+ if (nodeToMove) {
435
+ return Promise.allSettled(Array.from(players.values()).map(player => player.changeNode(nodeToMove.options.id)
436
+ .catch(error => {
437
+ if (enableDebugEvents) {
438
+ console.error(`Node > destroy() Failed to move player ${player.guildId}: ${error.message}`);
439
+ }
440
+ return player.destroy(error.message ?? DestroyReasons.PlayerChangeNodeFail)
441
+ .catch(destroyError => {
442
+ if (enableDebugEvents) {
443
+ console.error(`Node > destroy() Failed to destroy player ${player.guildId} after move failure: ${destroyError.message}`);
444
+ }
445
+ });
446
+ })));
447
+ }
448
+ else {
449
+ return Promise.allSettled(Array.from(players.values()).map(player => player.destroy(DestroyReasons.PlayerChangeNodeFailNoEligibleNode)
450
+ .catch(error => {
451
+ if (enableDebugEvents) {
452
+ console.error(`Node > destroy() Failed to destroy player ${player.guildId}: ${error.message}`);
453
+ }
454
+ })));
455
+ }
456
+ }
457
+ else {
458
+ return Promise.allSettled(Array.from(players.values()).map(player => player.destroy(destroyReason || DestroyReasons.NodeDestroy)
459
+ .catch(error => {
460
+ if (enableDebugEvents) {
461
+ console.error(`Node > destroy() Failed to destroy player ${player.guildId}: ${error.message}`);
462
+ }
463
+ })));
464
+ }
465
+ };
466
+ // Handle all player operations first, then clean up the socket
467
+ handlePlayerOperations().finally(() => {
468
+ this.socket.close(1000, "Node-Destroy");
469
+ this.socket.removeAllListeners();
470
+ this.socket = null;
471
+ this.reconnectAttempts = 1;
472
+ clearTimeout(this.reconnectTimeout);
473
+ if (deleteNode) {
474
+ this.NodeManager.emit("destroy", this, destroyReason);
475
+ this.NodeManager.nodes.delete(this.id);
476
+ clearInterval(this.heartBeatInterval);
477
+ clearTimeout(this.pingTimeout);
478
+ }
479
+ else {
480
+ this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason });
481
+ }
425
482
  });
426
- this.socket.close(1000, "Node-Destroy");
427
- this.socket.removeAllListeners();
428
- this.socket = null;
429
- this.reconnectAttempts = 1;
430
- clearTimeout(this.reconnectTimeout);
431
- if (deleteNode) {
432
- this.NodeManager.emit("destroy", this, destroyReason);
433
- this.NodeManager.nodes.delete(this.id);
434
483
  }
435
- else {
436
- this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason });
484
+ else { // If no players, proceed with socket cleanup immediately
485
+ this.socket.close(1000, "Node-Destroy");
486
+ this.socket.removeAllListeners();
487
+ this.socket = null;
488
+ this.reconnectAttempts = 1;
489
+ clearTimeout(this.reconnectTimeout);
490
+ if (deleteNode) {
491
+ this.NodeManager.emit("destroy", this, destroyReason);
492
+ this.NodeManager.nodes.delete(this.id);
493
+ clearInterval(this.heartBeatInterval);
494
+ clearTimeout(this.pingTimeout);
495
+ }
496
+ else {
497
+ this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason });
498
+ }
437
499
  }
438
500
  return;
439
501
  }
@@ -962,8 +1024,11 @@ export class LavalinkNode {
962
1024
  if (code === 1000 && reason === "Node-Disconnect")
963
1025
  return; // manually disconnected and already emitted the event.
964
1026
  this.NodeManager.emit("disconnect", this, { code, reason });
965
- if (code !== 1000 || reason !== "Node-Destroy")
966
- this.reconnect();
1027
+ if (code !== 1000 || reason !== "Node-Destroy") {
1028
+ if (this.NodeManager.nodes.has(this.id)) { // try to reconnect only when the node is still in the nodeManager.nodes list
1029
+ this.reconnect();
1030
+ }
1031
+ }
967
1032
  }
968
1033
  /** @private util function for handling error events from websocket */
969
1034
  error(error) {
@@ -1155,6 +1220,8 @@ export class LavalinkNode {
1155
1220
  }
1156
1221
  /** @private util function for handling trackEnd event */
1157
1222
  async trackEnd(player, track, payload) {
1223
+ if (player.get('internal_nodeChanging') === true)
1224
+ return; // Check if nodeChange is in Progress than stop the trackEnd Event from being triggered.
1158
1225
  const trackToUse = track || this.getTrackOfPayload(payload);
1159
1226
  // If a track was forcibly played
1160
1227
  if (payload.reason === "replaced") {
@@ -1370,6 +1437,8 @@ export class LavalinkNode {
1370
1437
  }
1371
1438
  /** private util function for handling the queue end event */
1372
1439
  async queueEnd(player, track, payload) {
1440
+ if (player.get('internal_nodeChanging') === true)
1441
+ return; // Check if nodeChange is in Progress than stop the queueEnd Event from being triggered.
1373
1442
  // add previous track to the queue!
1374
1443
  player.queue.current = null;
1375
1444
  player.playing = false;
@@ -83,7 +83,18 @@ export declare class NodeManager extends EventEmitter {
83
83
  /**
84
84
  * Delete a node from the nodeManager and destroy it
85
85
  * @param node The node to delete
86
+ * @param movePlayers whether to movePlayers to different connected node before deletion. @default false
86
87
  * @returns
88
+ *
89
+ * @example
90
+ * Deletes the node
91
+ * ```ts
92
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete");
93
+ * ```
94
+ * Moves players to a different node before deleting
95
+ * ```ts
96
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete", true);
97
+ * ```
87
98
  */
88
- deleteNode(node: LavalinkNodeIdentifier | LavalinkNode): void;
99
+ deleteNode(node: LavalinkNodeIdentifier | LavalinkNode, movePlayers?: boolean): void;
89
100
  }
@@ -193,13 +193,27 @@ export class NodeManager extends EventEmitter {
193
193
  /**
194
194
  * Delete a node from the nodeManager and destroy it
195
195
  * @param node The node to delete
196
+ * @param movePlayers whether to movePlayers to different connected node before deletion. @default false
196
197
  * @returns
198
+ *
199
+ * @example
200
+ * Deletes the node
201
+ * ```ts
202
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete");
203
+ * ```
204
+ * Moves players to a different node before deleting
205
+ * ```ts
206
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete", true);
207
+ * ```
197
208
  */
198
- deleteNode(node) {
209
+ deleteNode(node, movePlayers = false) {
199
210
  const decodeNode = typeof node === "string" ? this.nodes.get(node) : node || this.leastUsedNodes()[0];
200
211
  if (!decodeNode)
201
212
  throw new Error("Node was not found");
202
- decodeNode.destroy(DestroyReasons.NodeDeleted);
213
+ if (movePlayers)
214
+ decodeNode.destroy(DestroyReasons.NodeDeleted, true, true);
215
+ else
216
+ decodeNode.destroy(DestroyReasons.NodeDeleted);
203
217
  this.nodes.delete(decodeNode.id);
204
218
  return;
205
219
  }
@@ -217,8 +217,9 @@ export declare class Player {
217
217
  /**
218
218
  * Move the player on a different Audio-Node
219
219
  * @param newNode New Node / New Node Id
220
+ * @param checkSources If it should check if the sources are supported by the new node
220
221
  */
221
- changeNode(newNode: LavalinkNode | string): Promise<string>;
222
+ changeNode(newNode: LavalinkNode | string, checkSources?: boolean): Promise<string>;
222
223
  /** Converts the Player including Queue to a Json state */
223
224
  toJSON(): PlayerJson;
224
225
  }
@@ -456,11 +456,11 @@ export class Player {
456
456
  throw new RangeError("Can't skip more than the queue size");
457
457
  await this.queue.splice(0, skipTo - 1);
458
458
  }
459
- if (!this.playing)
459
+ if (!this.playing && !this.queue.current)
460
460
  return (this.play(), this);
461
461
  const now = performance.now();
462
462
  this.set("internal_skipped", true);
463
- await this.node.updatePlayer({ guildId: this.guildId, playerOptions: { track: { encoded: null } } });
463
+ await this.node.updatePlayer({ guildId: this.guildId, playerOptions: { track: { encoded: null }, paused: false } });
464
464
  this.ping.lavalink = Math.round((performance.now() - now) / 10) / 100;
465
465
  return this;
466
466
  }
@@ -636,11 +636,37 @@ export class Player {
636
636
  /**
637
637
  * Move the player on a different Audio-Node
638
638
  * @param newNode New Node / New Node Id
639
+ * @param checkSources If it should check if the sources are supported by the new node
639
640
  */
640
- async changeNode(newNode) {
641
+ async changeNode(newNode, checkSources = true) {
641
642
  const updateNode = typeof newNode === "string" ? this.LavalinkManager.nodeManager.nodes.get(newNode) : newNode;
642
643
  if (!updateNode)
643
644
  throw new Error("Could not find the new Node");
645
+ if (!updateNode.connected)
646
+ throw new Error("The provided Node is not active or disconnected");
647
+ if (this.node.id === updateNode.id)
648
+ throw new Error("Player is already on the provided Node");
649
+ if (this.get("internal_nodeChanging") === true)
650
+ throw new Error("Player is already changing the node please wait");
651
+ if (checkSources) {
652
+ const isDefaultSource = () => {
653
+ try {
654
+ this.LavalinkManager.utils.validateSourceString(updateNode, this.LavalinkManager.options.playerOptions.defaultSearchPlatform);
655
+ return true;
656
+ }
657
+ catch {
658
+ return false;
659
+ }
660
+ };
661
+ if (!isDefaultSource())
662
+ throw new RangeError(`defaultSearchPlatform "${this.LavalinkManager.options.playerOptions.defaultSearchPlatform}" is not supported by the newNode`);
663
+ if (this.queue.current || this.queue.tracks.length) { // Check if all queued track sources are supported by the new node
664
+ const trackSources = new Set([this.queue.current, ...this.queue.tracks].map(track => track.info.sourceName));
665
+ const missingSources = [...trackSources].filter(source => !updateNode.info.sourceManagers.includes(source));
666
+ if (missingSources.length)
667
+ throw new RangeError(`Sources missing for Node ${updateNode.id}: ${missingSources.join(', ')}`);
668
+ }
669
+ }
644
670
  if (this.LavalinkManager.options?.advancedOptions?.enableDebugEvents) {
645
671
  this.LavalinkManager.emit("debug", DebugEvents.PlayerChangeNode, {
646
672
  state: "log",
@@ -650,23 +676,60 @@ export class Player {
650
676
  }
651
677
  const data = this.toJSON();
652
678
  const currentTrack = this.queue.current;
653
- await this.node.destroyPlayer(this.guildId);
679
+ const voiceData = this.voice;
680
+ if (!voiceData.endpoint ||
681
+ !voiceData.sessionId ||
682
+ !voiceData.token)
683
+ throw new Error("Voice Data is missing, can't change the node");
684
+ this.set("internal_nodeChanging", true); // This will stop execution of trackEnd or queueEnd event while changing the node
685
+ if (this.node.connected)
686
+ await this.node.destroyPlayer(this.guildId); // destroy the player on the currentNode if it's connected
654
687
  this.node = updateNode;
655
688
  const now = performance.now();
656
- await this.connect();
657
- await this.node.updatePlayer({
658
- guildId: this.guildId,
659
- noReplace: false,
660
- playerOptions: {
661
- position: data.position,
662
- volume: Math.round(Math.max(Math.min(data.volume, 1000), 0)),
663
- paused: data.paused,
664
- filters: { ...data.filters, equalizer: data.equalizer },
665
- track: currentTrack ?? undefined
666
- },
667
- });
668
- this.ping.lavalink = Math.round((performance.now() - now) / 10) / 100;
669
- return this.node.id;
689
+ try {
690
+ await this.connect();
691
+ const endpoint = `/sessions/${this.node.sessionId}/players/${this.guildId}`; //Send the VoiceData to the newly connected node.
692
+ await this.node.request(endpoint, r => {
693
+ r.method = "PATCH";
694
+ r.headers["Content-Type"] = "application/json";
695
+ r.body = JSON.stringify({
696
+ voice: {
697
+ token: voiceData.token,
698
+ endpoint: voiceData.endpoint,
699
+ sessionId: voiceData.sessionId
700
+ }
701
+ });
702
+ });
703
+ if (currentTrack) { // If there is a current track, send it to the new node.
704
+ await this.node.updatePlayer({
705
+ guildId: this.guildId,
706
+ noReplace: false,
707
+ playerOptions: {
708
+ track: currentTrack ?? null,
709
+ position: currentTrack ? data.position : 0,
710
+ volume: data.lavalinkVolume,
711
+ paused: data.paused,
712
+ //filters: { ...data.filters, equalizer: data.equalizer }, Sending filters on nodeChange causes issues (player gets dicsonnected)
713
+ }
714
+ });
715
+ }
716
+ this.ping.lavalink = Math.round((performance.now() - now) / 10) / 100;
717
+ return this.node.id;
718
+ }
719
+ catch (error) {
720
+ if (this.LavalinkManager.options?.advancedOptions?.enableDebugEvents) {
721
+ this.LavalinkManager.emit("debug", DebugEvents.PlayerChangeNode, {
722
+ state: "error",
723
+ error: error,
724
+ message: `Player.changeNode() execution failed`,
725
+ functionLayer: "Player > changeNode()",
726
+ });
727
+ }
728
+ throw new Error(`Failed to change the node: ${error}`);
729
+ }
730
+ finally {
731
+ this.set("internal_nodeChanging", undefined);
732
+ }
670
733
  }
671
734
  /** Converts the Player including Queue to a Json state */
672
735
  toJSON() {
@@ -49,6 +49,8 @@ export declare enum DestroyReasons {
49
49
  NodeReconnectFail = "NodeReconnectFail",
50
50
  Disconnected = "Disconnected",
51
51
  PlayerReconnectFail = "PlayerReconnectFail",
52
+ PlayerChangeNodeFail = "PlayerChangeNodeFail",
53
+ PlayerChangeNodeFailNoEligibleNode = "PlayerChangeNodeFailNoEligibleNode",
52
54
  ChannelDeleted = "ChannelDeleted",
53
55
  DisconnectAllNodes = "DisconnectAllNodes",
54
56
  ReconnectAllNodes = "ReconnectAllNodes",
@@ -173,14 +173,20 @@ export declare class LavalinkNode {
173
173
  * Destroys the Node-Connection (Websocket) and all player's of the node
174
174
  * @param destroyReason Destroy Reason to use when destroying the players
175
175
  * @param deleteNode wether to delete the nodte from the nodes list too, if false it will emit a disconnect. @default true
176
+ * @param movePlayers whether to movePlayers to different eligible connected node. If false players won't be moved @default false
176
177
  * @returns void
177
178
  *
178
179
  * @example
180
+ * Destroys node and its players
179
181
  * ```ts
180
182
  * player.node.destroy("custom Player Destroy Reason", true);
181
183
  * ```
184
+ * destroys only the node and moves its players to different connected node.
185
+ * ```ts
186
+ * player.node.destroy("custom Player Destroy Reason", true, true);
187
+ * ```
182
188
  */
183
- destroy(destroyReason?: DestroyReasonsType, deleteNode?: boolean): void;
189
+ destroy(destroyReason?: DestroyReasonsType, deleteNode?: boolean, movePlayers?: boolean): void;
184
190
  /**
185
191
  * Disconnects the Node-Connection (Websocket)
186
192
  * @param disconnectReason Disconnect Reason to use when disconnecting Node
@@ -83,7 +83,18 @@ export declare class NodeManager extends EventEmitter {
83
83
  /**
84
84
  * Delete a node from the nodeManager and destroy it
85
85
  * @param node The node to delete
86
+ * @param movePlayers whether to movePlayers to different connected node before deletion. @default false
86
87
  * @returns
88
+ *
89
+ * @example
90
+ * Deletes the node
91
+ * ```ts
92
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete");
93
+ * ```
94
+ * Moves players to a different node before deleting
95
+ * ```ts
96
+ * client.lavalink.nodeManager.deleteNode("nodeId to delete", true);
97
+ * ```
87
98
  */
88
- deleteNode(node: LavalinkNodeIdentifier | LavalinkNode): void;
99
+ deleteNode(node: LavalinkNodeIdentifier | LavalinkNode, movePlayers?: boolean): void;
89
100
  }
@@ -217,8 +217,9 @@ export declare class Player {
217
217
  /**
218
218
  * Move the player on a different Audio-Node
219
219
  * @param newNode New Node / New Node Id
220
+ * @param checkSources If it should check if the sources are supported by the new node
220
221
  */
221
- changeNode(newNode: LavalinkNode | string): Promise<string>;
222
+ changeNode(newNode: LavalinkNode | string, checkSources?: boolean): Promise<string>;
222
223
  /** Converts the Player including Queue to a Json state */
223
224
  toJSON(): PlayerJson;
224
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavalink-client",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "Easy, flexible and feature-rich lavalink@v4 Client. Both for Beginners and Proficients.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -58,22 +58,22 @@
58
58
  },
59
59
  "homepage": "https://tomato6966.github.io/lavalink-client/",
60
60
  "devDependencies": {
61
- "@eslint/eslintrc": "^3.1.0",
62
- "@eslint/js": "^9.11.0",
63
- "@types/node": "^22.5.5",
64
- "@types/ws": "^8.5.12",
65
- "@typescript-eslint/eslint-plugin": "^8.6.0",
66
- "@typescript-eslint/parser": "^8.6.0",
67
- "eslint": "^9.11.0",
61
+ "@eslint/eslintrc": "^3.2.0",
62
+ "@eslint/js": "^9.18.0",
63
+ "@types/node": "^22.10.5",
64
+ "@types/ws": "^8.5.13",
65
+ "@typescript-eslint/eslint-plugin": "^8.20.0",
66
+ "@typescript-eslint/parser": "^8.20.0",
67
+ "eslint": "^9.18.0",
68
68
  "tsc-alias": "^1.8.10",
69
- "typescript": "^5.6.2"
69
+ "typescript": "^5.7.3"
70
70
  },
71
71
  "dependencies": {
72
- "tslib": "^2.7.0",
72
+ "tslib": "^2.8.1",
73
73
  "ws": "^8.18.0"
74
74
  },
75
75
  "engines": {
76
76
  "node": ">=18.0.0",
77
- "bun": ">=1.0.0"
77
+ "bun": ">=1.1.27"
78
78
  }
79
79
  }