magmastream
Version:
A user-friendly Lavalink client designed for NodeJS.
1,068 lines • 56.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Node = void 0;
const tslib_1 = require("tslib");
const Utils_1 = require("./Utils");
const Rest_1 = require("./Rest");
const nodeCheck_1 = tslib_1.__importDefault(require("../utils/nodeCheck"));
const ws_1 = tslib_1.__importDefault(require("ws"));
const fs_1 = tslib_1.__importDefault(require("fs"));
const path_1 = tslib_1.__importDefault(require("path"));
const Enums_1 = require("./Enums");
const MagmastreamError_1 = require("./MagmastreamError");
const validSponsorBlocks = Object.values(Enums_1.SponsorBlockSegment).map((v) => v.toLowerCase());
class Node {
manager;
options;
/** The socket for the node. */
socket = null;
/** The stats for the node. */
stats;
/** The manager for the node */
// public manager: Manager;
/** The node's session ID. */
sessionId;
/** The REST instance. */
rest;
/** Actual Lavalink information of the node. */
info = null;
/** Whether the node is a NodeLink. */
isNodeLink = false;
reconnectTimeout;
reconnectAttempts = 1;
redisPrefix;
sessionIdsMap = new Map();
/**
* Creates an instance of Node.
* @param manager - The manager for the node.
* @param options - The options for the node.
*/
constructor(manager, options) {
this.manager = manager;
this.options = options;
if (!this.manager) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.GENERAL_INVALID_MANAGER,
message: "Manager instance is required.",
});
}
if (this.manager.nodes.has(options.identifier || options.host)) {
return this.manager.nodes.get(options.identifier || options.host);
}
(0, nodeCheck_1.default)(options);
this.options = {
...options,
host: options.host ?? "localhost",
port: options.port ?? 2333,
password: options.password ?? "youshallnotpass",
useSSL: options.useSSL ?? false,
identifier: options.identifier ?? options.host,
maxRetryAttempts: options.maxRetryAttempts ?? 30,
retryDelayMs: options.retryDelayMs ?? 60000,
enableSessionResumeOption: options.enableSessionResumeOption ?? false,
sessionTimeoutSeconds: options.sessionTimeoutSeconds ?? 60,
apiRequestTimeoutMs: options.apiRequestTimeoutMs ?? 10000,
nodePriority: options.nodePriority ?? 0,
isNodeLink: options.isNodeLink ?? false,
isBackup: options.isBackup ?? false,
};
if (this.options.useSSL) {
this.options.port = 443;
}
this.stats = {
players: 0,
playingPlayers: 0,
uptime: 0,
memory: {
free: 0,
used: 0,
allocated: 0,
reservable: 0,
},
cpu: {
cores: 0,
systemLoad: 0,
lavalinkLoad: 0,
},
frameStats: {
sent: 0,
nulled: 0,
deficit: 0,
},
};
this.manager.nodes.set(this.options.identifier, this);
this.manager.emit(Enums_1.ManagerEventTypes.NodeCreate, this);
this.rest = new Rest_1.Rest(this, this.manager);
switch (this.manager.options.stateStorage.type) {
case Enums_1.StateStorageType.Memory:
case Enums_1.StateStorageType.JSON:
this.createReadmeFile();
break;
case Enums_1.StateStorageType.Redis:
this.redisPrefix = Utils_1.PlayerUtils.getRedisKey();
break;
}
}
/**
* Checks if the Node is currently connected.
* This method returns true if the Node has an active WebSocket connection, indicating it is ready to receive and process commands.
*/
get connected() {
if (!this.socket)
return false;
return this.socket.readyState === ws_1.default.OPEN;
}
/** Returns the full address for this node, including the host and port. */
get address() {
return `${this.options.host}:${this.options.port}`;
}
getCompositeKey() {
return `${this.options.identifier}::${this.manager.options.clusterId}`;
}
getRedisSessionIdsKey() {
return `${this.redisPrefix}node:sessionIds`;
}
getNodeSessionsDir() {
return path_1.default.join(process.cwd(), "magmastream", "sessionData", "nodeSessions");
}
getNodeSessionPath() {
const safeId = String(this.options.identifier).replace(/[^a-zA-Z0-9._-]/g, "_");
const clusterId = String(this.manager.options.clusterId ?? 0);
return path_1.default.join(this.getNodeSessionsDir(), `${safeId}__${clusterId}.txt`);
}
/**
* Loads session IDs from the sessionIds.json file if it exists.
* The session IDs are used to resume sessions for each node.
*
* The session IDs are stored in the sessionIds.json file as a composite key
* of the node identifier and cluster ID. This allows multiple clusters to
* be used with the same node identifier.
*/
async loadSessionIds() {
switch (this.manager.options.stateStorage.type) {
case Enums_1.StateStorageType.Memory:
case Enums_1.StateStorageType.JSON: {
const dir = this.getNodeSessionsDir();
const filePath = this.getNodeSessionPath();
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
if (!fs_1.default.existsSync(filePath)) {
this.sessionId = null;
return;
}
try {
const raw = fs_1.default.readFileSync(filePath, "utf-8").trim();
this.sessionId = raw.length ? raw : null;
if (this.sessionId)
this.sessionIdsMap.set(this.getCompositeKey(), this.sessionId);
}
catch {
this.sessionId = null;
}
break;
}
case Enums_1.StateStorageType.Redis: {
const key = this.getRedisSessionIdsKey();
const compositeKey = this.getCompositeKey();
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Loading sessionId from Redis hash: ${key} field: ${compositeKey}`);
try {
const sid = await this.manager.redis.hget(key, compositeKey);
this.sessionId = sid ?? null;
if (this.sessionId) {
this.sessionIdsMap.set(compositeKey, this.sessionId);
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Restored sessionId for ${compositeKey}: ${this.sessionId}`);
}
}
catch (err) {
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Failed to load sessionId from Redis hash: ${err.message}`);
this.sessionId = null;
}
break;
}
}
}
/**
* Updates the session ID in the sessionIds.json file.
*
* This method is called after the session ID has been updated, and it
* writes the new session ID to the sessionIds.json file.
*
* @remarks
* The session ID is stored in the sessionIds.json file as a composite key
* of the node identifier and cluster ID. This allows multiple clusters to
* be used with the same node identifier.
*/
async updateSessionId() {
switch (this.manager.options.stateStorage.type) {
case Enums_1.StateStorageType.Memory:
case Enums_1.StateStorageType.JSON:
return this.updateSessionIdFile();
case Enums_1.StateStorageType.Redis:
return this.updateSessionIdRedis();
}
}
async updateSessionIdFile() {
const dir = this.getNodeSessionsDir();
const filePath = this.getNodeSessionPath();
const tmpPath = `${filePath}.tmp`;
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
if (this.sessionId) {
fs_1.default.writeFileSync(tmpPath, this.sessionId, "utf-8");
fs_1.default.renameSync(tmpPath, filePath);
this.sessionIdsMap.set(this.getCompositeKey(), this.sessionId);
}
else {
try {
if (fs_1.default.existsSync(filePath))
fs_1.default.unlinkSync(filePath);
}
catch { }
this.sessionIdsMap.delete(this.getCompositeKey());
}
}
async updateSessionIdRedis() {
const key = this.getRedisSessionIdsKey();
const compositeKey = this.getCompositeKey();
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Updating sessionId in Redis hash: ${key} field: ${compositeKey}`);
try {
if (this.sessionId) {
await this.manager.redis.hset(key, compositeKey, this.sessionId);
this.sessionIdsMap.set(compositeKey, this.sessionId);
}
else {
await this.manager.redis.hdel(key, compositeKey);
this.sessionIdsMap.delete(compositeKey);
}
}
catch (err) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_SESSION_IDS_UPDATE_FAILED,
message: "Failed to update sessionId in Redis hash.",
cause: err instanceof Error ? err : undefined,
context: { key, compositeKey, storage: "redis-hash" },
});
}
}
/**
* Connects to the Node.
*
* @remarks
* If the node is already connected, this method will do nothing.
* If the node has a session ID, it will be sent in the headers of the WebSocket connection.
* If the node has no session ID but the `enableSessionResumeOption` option is true, it will use the session ID
* stored in the sessionIds.json file if it exists.
*/
async connect() {
await this.loadSessionIds();
if (this.connected)
return;
const headers = {
Authorization: this.options.password,
"User-Id": this.manager.options.clientId,
"Client-Name": this.manager.options.clientName,
};
if (typeof this.sessionId === "string" && this.sessionId.length > 0) {
headers["Session-Id"] = this.sessionId;
}
this.socket = new ws_1.default(`ws${this.options.useSSL ? "s" : ""}://${this.address}/v4/websocket`, { headers });
this.socket.on("open", this.open.bind(this));
this.socket.on("close", this.close.bind(this));
this.socket.on("upgrade", (request) => this.upgrade(request));
this.socket.on("message", this.message.bind(this));
this.socket.on("error", this.error.bind(this));
const debugInfo = {
connected: this.connected,
address: this.address,
sessionId: this.sessionId,
options: {
clientId: this.manager.options.clientId,
clientName: this.manager.options.clientName,
useSSL: this.options.useSSL,
identifier: this.options.identifier,
},
};
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Connecting ${Utils_1.JSONUtils.safe(debugInfo, 2)}`);
}
/**
* Destroys the node and cleans up associated resources.
*
* This method emits a debug event indicating that the node is being destroyed and attempts
* to automatically move all players connected to the node to a usable one. It then closes
* the WebSocket connection, removes all event listeners, and clears the reconnect timeout.
* Finally, it emits a "nodeDestroy" event and removes the node from the manager.
*
* @returns {Promise<void>} A promise that resolves when the node and its resources have been destroyed.
*/
async destroy() {
if (!this.connected)
return;
const debugInfo = {
connected: this.connected,
identifier: this.options.identifier,
address: this.address,
sessionId: this.sessionId,
playerCount: this.manager.players.filter((p) => p.node == this).size,
};
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Destroying node: ${Utils_1.JSONUtils.safe(debugInfo, 2)}`);
// Automove all players connected to that node
const players = this.manager.players.filter((p) => p.node == this);
if (players.size) {
await Promise.all(Array.from(players.values(), (player) => player.autoMoveNode()));
}
this.socket.close(1000, "destroy");
this.socket.removeAllListeners();
this.reconnectAttempts = 1;
clearTimeout(this.reconnectTimeout);
this.manager.emit(Enums_1.ManagerEventTypes.NodeDestroy, this);
this.manager.nodes.delete(this.options.identifier);
}
/**
* Attempts to reconnect to the node if the connection is lost.
*
* This method is called when the WebSocket connection is closed
* unexpectedly. It will attempt to reconnect to the node after a
* specified delay, and will continue to do so until the maximum
* number of retry attempts is reached or the node is manually destroyed.
* If the maximum number of retry attempts is reached, an error event
* will be emitted and the node will be destroyed.
*
* @returns {Promise<void>} - Resolves when the reconnection attempt is scheduled.
* @emits {debug} - Emits a debug event indicating the node is attempting to reconnect.
* @emits {nodeReconnect} - Emits a nodeReconnect event when the node is attempting to reconnect.
* @emits {nodeError} - Emits an error event if the maximum number of retry attempts is reached.
* @emits {nodeDestroy} - Emits a nodeDestroy event if the maximum number of retry attempts is reached.
*/
async reconnect() {
const debugInfo = {
identifier: this.options.identifier,
connected: this.connected,
reconnectAttempts: this.reconnectAttempts,
maxRetryAttempts: this.options.maxRetryAttempts,
retryDelayMs: this.options.retryDelayMs,
};
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Reconnecting node: ${Utils_1.JSONUtils.safe(debugInfo, 2)}`);
this.reconnectTimeout = setTimeout(async () => {
if (this.reconnectAttempts >= this.options.maxRetryAttempts) {
const error = new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_RECONNECT_FAILED,
message: `Unable to reconnect after ${this.options.maxRetryAttempts} attempts.`,
context: { ...debugInfo },
});
this.manager.emit(Enums_1.ManagerEventTypes.NodeError, this, error);
return await this.destroy();
}
this.socket?.removeAllListeners();
this.socket = null;
this.manager.emit(Enums_1.ManagerEventTypes.NodeReconnect, this);
await this.connect();
this.reconnectAttempts++;
}, this.options.retryDelayMs);
}
/**
* Upgrades the node to a NodeLink.
*
* @param request - The incoming message.
*/
upgrade(request) {
this.isNodeLink = this.options.isNodeLink ?? Boolean(request.headers.isnodelink) ?? false;
}
/**
* Handles the "open" event emitted by the WebSocket connection.
*
* This method is called when the WebSocket connection is established.
* It clears any existing reconnect timeouts, emits a debug event
* indicating the node is connected, and emits a "nodeConnect" event
* with the node as the argument.
*/
open() {
if (this.reconnectTimeout)
clearTimeout(this.reconnectTimeout);
const debugInfo = {
identifier: this.options.identifier,
connected: this.connected,
};
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Connected node: ${Utils_1.JSONUtils.safe(debugInfo, 2)}`);
this.manager.emit(Enums_1.ManagerEventTypes.NodeConnect, this);
const playersOnBackupNode = this.manager.players.filter((p) => p.node.options.isBackup);
if (playersOnBackupNode.size) {
Promise.all(Array.from(playersOnBackupNode.values(), (player) => player.moveNode(this.options.identifier)));
}
}
/**
* Handles the "close" event emitted by the WebSocket connection.
*
* This method is called when the WebSocket connection is closed.
* It emits a "nodeDisconnect" event with the node and the close event as arguments,
* and a debug event indicating the node is disconnected.
* It then attempts to move all players connected to that node to a useable one.
* If the close event was not initiated by the user, it will also attempt to reconnect.
*
* @param {number} code The close code of the WebSocket connection.
* @param {string} reason The reason for the close event.
* @returns {Promise<void>} A promise that resolves when the disconnection is handled.
*/
async close(code, reason) {
const debugInfo = {
identifier: this.options.identifier,
code,
reason,
};
this.manager.emit(Enums_1.ManagerEventTypes.NodeDisconnect, this, { code, reason });
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Disconnected node: ${Utils_1.JSONUtils.safe(debugInfo, 2)}`);
if (this.manager.useableNode) {
const players = this.manager.players.filter((p) => p.node.options.identifier == this.options.identifier);
if (players.size) {
await Promise.all(Array.from(players.values(), (player) => player.autoMoveNode()));
}
}
else {
const backUpNodes = this.manager.nodes.filter((node) => node.options.isBackup && node.connected);
const backupNode = backUpNodes.first();
if (backupNode) {
await Promise.all(Array.from(this.manager.players.values(), (player) => player.moveNode(backupNode.options.identifier)));
}
}
if (code !== 1000 || reason !== "destroy")
await this.reconnect();
}
/**
* Handles the "error" event emitted by the WebSocket connection.
*
* This method is called when an error occurs on the WebSocket connection.
* It emits a "nodeError" event with the node and the error as arguments and
* a debug event indicating the error on the node.
* @param {Error} error The error that occurred.
*/
error(error) {
if (!error)
return;
const debugInfo = {
identifier: this.options.identifier,
error: error.message,
};
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Error on node: ${Utils_1.JSONUtils.safe(debugInfo, 2)}`);
this.manager.emit(Enums_1.ManagerEventTypes.NodeError, this, error);
}
/**
* Handles incoming messages from the Lavalink WebSocket connection.
* @param {Buffer | string} d The message received from the WebSocket connection.
* @returns {Promise<void>} A promise that resolves when the message is handled.
* @emits {debug} - Emits a debug event with the message received from the WebSocket connection.
* @emits {nodeError} - Emits a nodeError event if an unexpected op is received.
* @emits {nodeRaw} - Emits a nodeRaw event with the raw message received from the WebSocket connection.
* @private
*/
async message(d) {
if (Array.isArray(d))
d = Buffer.concat(d);
else if (d instanceof ArrayBuffer)
d = Buffer.from(d);
const payload = JSON.parse(d.toString());
if (!payload.op)
return;
this.manager.emit(Enums_1.ManagerEventTypes.NodeRaw, payload);
let player;
switch (payload.op) {
case "stats":
delete payload.op;
this.stats = { ...payload };
break;
case "playerUpdate":
player = this.manager.players.get(payload.guildId);
if (player && player.node.options.identifier !== this.options.identifier) {
return;
}
if (player)
player.position = payload.state.position || 0;
break;
case "event":
player = this.manager.players.get(payload.guildId);
if (player && player.node.options.identifier !== this.options.identifier) {
return;
}
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Node message: ${Utils_1.JSONUtils.safe(payload, 2)}`);
await this.handleEvent(payload);
break;
case "ready":
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Node message: ${Utils_1.JSONUtils.safe(payload, 2)}`);
this.rest.setSessionId(payload.sessionId);
const hadPreviousSession = this.sessionId && this.sessionId !== payload.sessionId;
this.sessionId = payload.sessionId;
await this.updateSessionId();
this.info = await this.fetchInfo();
if (payload.resumed || !hadPreviousSession) {
await this.manager.loadPlayerStates(this.options.identifier);
}
if (this.options.enableSessionResumeOption) {
await this.rest.patch(`/v4/sessions/${this.sessionId}`, {
resuming: this.options.enableSessionResumeOption,
timeout: this.options.sessionTimeoutSeconds,
});
}
break;
default:
this.manager.emit(Enums_1.ManagerEventTypes.NodeError, this, new Error(`Unexpected op "${payload.op}" with data: ${payload.message}`));
return;
}
}
/**
* Handles an event emitted from the Lavalink node.
* @param {PlayerEvent & PlayerEvents} payload The event emitted from the node.
* @returns {Promise<void>} A promise that resolves when the event has been handled.
* @private
*/
async handleEvent(payload) {
if (!payload.guildId)
return;
const player = this.manager.players.get(payload.guildId);
if (!player)
return;
const track = await player.queue.getCurrent();
const type = payload.type;
let error;
switch (type) {
case "TrackStartEvent":
this.trackStart(player, track, payload);
break;
case "TrackEndEvent":
if (player?.nowPlayingMessage) {
if ("delete" in player.nowPlayingMessage && typeof player.nowPlayingMessage.delete === "function") {
await player.nowPlayingMessage.delete().catch(() => { });
}
player.nowPlayingMessage = null;
player.set("nowPlayingMessage", null);
}
await this.trackEnd(player, track, payload);
break;
case "TrackStuckEvent":
await this.trackStuck(player, track, payload);
break;
case "TrackExceptionEvent":
await this.trackError(player, track, payload);
break;
case "WebSocketClosedEvent":
this.socketClosed(player, payload);
break;
case "SegmentsLoaded":
this.sponsorBlockSegmentLoaded(player, track, payload);
break;
case "SegmentSkipped":
this.sponsorBlockSegmentSkipped(player, track, payload);
break;
case "ChaptersLoaded":
this.sponsorBlockChaptersLoaded(player, track, payload);
break;
case "ChapterStarted":
this.sponsorBlockChapterStarted(player, track, payload);
break;
case "LyricsFoundEvent":
this.lyricsFound(player, track, payload);
break;
case "LyricsNotFoundEvent":
this.lyricsNotFound(player, track, payload);
break;
case "LyricsLineEvent":
this.lyricsLine(player, track, payload);
break;
default:
error = new Error(`Node#event unknown event '${type}'.`);
this.manager.emit(Enums_1.ManagerEventTypes.NodeError, this, error);
break;
}
}
/**
* Emitted when a new track starts playing.
* @param {Player} player The player that started playing the track.
* @param {Track} track The track that started playing.
* @param {TrackStartEvent} payload The payload of the event emitted by the node.
* @private
*/
trackStart(player, track, payload) {
const oldPlayer = player;
player.playing = true;
player.paused = false;
this.manager.emit(Enums_1.ManagerEventTypes.TrackStart, player, track, payload);
if (track.isAutoplay) {
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, player, {
changeType: Enums_1.PlayerStateEventTypes.TrackChange,
details: {
type: "track",
action: "autoPlay",
track,
},
});
return;
}
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, player, {
changeType: Enums_1.PlayerStateEventTypes.TrackChange,
details: {
type: "track",
action: "start",
track,
},
});
}
/**
* Emitted when a track ends playing.
* @param {Player} player - The player that the track ended on.
* @param {Track} track - The track that ended.
* @param {TrackEndEvent} payload - The payload of the event emitted by the node.
* @private
*/
async trackEnd(player, track, payload) {
const { reason } = payload;
const skipFlag = player.get("skipFlag");
const previous = await player.queue.getPrevious();
const current = await player.queue.getCurrent();
// Only add current to previous if it's not already the newest
if (!skipFlag && (previous.length === 0 || previous.at(-1)?.track !== current?.track)) {
await player.queue.addPrevious(current);
}
player.set("skipFlag", false);
const oldPlayer = player;
switch (reason) {
case Enums_1.TrackEndReasonTypes.LoadFailed:
case Enums_1.TrackEndReasonTypes.Cleanup:
await this.handleFailedTrack(player, track, payload);
break;
case Enums_1.TrackEndReasonTypes.Replaced:
break;
case Enums_1.TrackEndReasonTypes.Stopped:
if (await player.queue.size()) {
await this.playNextTrack(player, track, payload);
}
else {
await this.queueEnd(player, track, payload);
}
break;
case Enums_1.TrackEndReasonTypes.Finished:
// If the track ended and it's set to repeat (track or queue)
if (track && (player.trackRepeat || player.queueRepeat)) {
await this.handleRepeatedTrack(player, track, payload);
break;
}
// If there's another track in the queue
if (await player.queue.size()) {
await this.playNextTrack(player, track, payload);
}
else {
await this.queueEnd(player, track, payload);
}
break;
default:
this.manager.emit(Enums_1.ManagerEventTypes.NodeError, this, new Error(`Unexpected track end reason "${reason}"`));
break;
}
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, player, {
changeType: Enums_1.PlayerStateEventTypes.TrackChange,
details: {
type: "track",
action: "end",
track: track,
},
});
}
/**
* Handles autoplay logic for a player.
* This method is responsible for selecting an appropriate method of autoplay
* and executing it. If autoplay is not enabled or all attempts have failed,
* it will return false.
* @param {Player} player - The player to handle autoplay for.
* @param {number} attempt - The current attempt number of the autoplay.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if autoplay was successful.
* @private
*/
async handleAutoplay(player, attempt = 0) {
// If autoplay is not enabled or all attempts have failed, early exit
if (!player.isAutoplay || attempt > player.autoplayTries || !(await player.queue.getPrevious()).length)
return false;
const PreviousQueue = await player.queue.getPrevious();
const lastTrack = PreviousQueue.at(-1); // newest is at tail
if (!lastTrack)
return false;
lastTrack.requester = player.get("Internal_AutoplayUser");
const tracks = await Utils_1.AutoPlayUtils.getRecommendedTracks(lastTrack);
const normalize = (str) => str
.toLowerCase()
.replace(/\s+/g, "")
.replace(/[^a-z0-9]/g, "");
const filteredTracks = tracks.filter((track) => track.identifier !== lastTrack.identifier && track.uri !== lastTrack.uri && normalize(track.title) !== normalize(lastTrack.title));
if (filteredTracks.length) {
const randomTrack = filteredTracks[Math.floor(Math.random() * filteredTracks.length)];
await player.queue.add(randomTrack);
await player.play();
return true;
}
else {
return false;
}
}
/**
* Handles the scenario when a track fails to play or load.
* Shifts the queue to the next track and emits a track end event.
* If there is no next track, handles the queue end scenario.
* If autoplay is enabled, plays the next track.
*
* @param {Player} player - The player instance associated with the track.
* @param {Track} track - The track that failed.
* @param {TrackEndEvent} payload - The event payload containing details about the track end.
* @returns {Promise<void>} A promise that resolves when the track failure has been processed.
* @private
*/
async handleFailedTrack(player, track, payload) {
await player.queue.setCurrent(await player.queue.dequeue());
if (!(await player.queue.getCurrent())) {
await this.queueEnd(player, track, payload);
return;
}
this.manager.emit(Enums_1.ManagerEventTypes.TrackEnd, player, track, payload);
if (this.manager.options.playNextOnEnd)
await player.play();
}
/**
* Handles the scenario when a track is repeated.
* Shifts the queue to the next track and emits a track end event.
* If there is no next track, handles the queue end scenario.
* If autoplay is enabled, plays the next track.
*
* @param {Player} player - The player instance associated with the track.
* @param {Track} track - The track that is repeated.
* @param {TrackEndEvent} payload - The event payload containing details about the track end.
* @returns {Promise<void>} A promise that resolves when the repeated track has been processed.
* @private
*/
async handleRepeatedTrack(player, track, payload) {
const { queue, trackRepeat, queueRepeat } = player;
const { playNextOnEnd } = this.manager.options;
if (trackRepeat) {
// Prevent duplicate repeat insertion
if (queue[0] !== (await queue.getCurrent())) {
await queue.enqueueFront(await queue.getCurrent());
}
}
else if (queueRepeat) {
// Prevent duplicate queue insertion
if (queue[(await queue.size()) - 1] !== (await queue.getCurrent())) {
await queue.add(await queue.getCurrent());
}
}
// Move to the next track
await queue.setCurrent(await queue.dequeue());
this.manager.emit(Enums_1.ManagerEventTypes.TrackEnd, player, track, payload);
if (payload.reason === Enums_1.TrackEndReasonTypes.Stopped) {
const next = await queue.dequeue();
await queue.setCurrent(next ?? null);
if (!next) {
await this.queueEnd(player, track, payload);
return;
}
}
if (playNextOnEnd)
await player.play();
}
/**
* Plays the next track in the queue.
* Updates the queue by shifting the current track to the previous track
* and plays the next track if autoplay is enabled.
*
* @param {Player} player - The player associated with the track.
* @param {Track} track - The track that has ended.
* @param {TrackEndEvent} payload - The event payload containing additional data about the track end event.
* @returns {void}
* @private
*/
async playNextTrack(player, track, payload) {
// Shift the queue to set the next track as current
await player.queue.setCurrent(await player.queue.dequeue());
this.manager.emit(Enums_1.ManagerEventTypes.TrackEnd, player, track, payload);
if (this.manager.options.playNextOnEnd)
await player.play();
}
/**
* Handles the event when a queue ends.
* If autoplay is enabled, attempts to play the next track in the queue using the autoplay logic.
* If all attempts fail, resets the player state and emits the `queueEnd` event.
* @param {Player} player - The player associated with the track.
* @param {Track} track - The track that has ended.
* @param {TrackEndEvent} payload - The event payload containing additional data about the track end event.
* @returns {Promise<void>} A promise that resolves when the queue end processing is complete.
*/
async queueEnd(player, track, payload) {
await player.queue.setCurrent(null);
if (!player.isAutoplay) {
player.playing = false;
this.manager.emit(Enums_1.ManagerEventTypes.QueueEnd, player, track, payload);
return;
}
let attempt = 1;
let success = false;
while (attempt <= player.autoplayTries) {
success = await this.handleAutoplay(player, attempt);
if (success)
return;
attempt++;
}
player.playing = false;
this.manager.emit(Enums_1.ManagerEventTypes.QueueEnd, player, track, payload);
}
/**
* Fetches the lyrics of a track from the Lavalink node.
*
* If the node is a NodeLink, it will use the `NodeLinkGetLyrics` method to fetch the lyrics.
*
* Requires the `lavalyrics-plugin` to be present in the Lavalink node.
* Requires the `lavasrc-plugin` or `java-lyrics-plugin` to be present in the Lavalink node.
*
* @param {Track} track - The track to fetch the lyrics for.
* @param {boolean} [skipTrackSource=false] - Whether to skip using the track's source URL.
* @param {string} [language="en"] - The language of the lyrics.
* @returns {Promise<Lyrics | NodeLinkGetLyrics>} A promise that resolves with the lyrics data.
*/
async getLyrics(track, skipTrackSource = false, language) {
if (!this.connected) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_DISCONNECTED,
message: `The node is not connected to the lavalink server.`,
context: { identifier: this.options.identifier },
});
}
if (this.isNodeLink) {
return (await this.rest.get(`/v4/loadlyrics?encodedTrack=${encodeURIComponent(track.track)}${language ? `&language=${language}` : ""}`));
}
const requiredPlugins = ["lavalyrics-plugin"];
for (const plugin of requiredPlugins) {
if (!this.info.plugins.some((p) => p.name === plugin)) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PLUGIN_ERROR,
message: `The plugin "${plugin}" must be present in the lavalink node.`,
context: { identifier: this.options.identifier },
});
}
}
if (!this.info.plugins.some((p) => p.name === "lavasrc-plugin" || p.name === "java-lyrics-plugin")) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PLUGIN_ERROR,
message: `One of the following plugins must also be present in the lavalink node: "lavasrc-plugin" or "java-lyrics-plugin".`,
context: { identifier: this.options.identifier },
});
}
const lyrics = (await this.rest.get(`/v4/lyrics?track=${encodeURIComponent(track.track)}&skipTrackSource=${skipTrackSource}`));
return lyrics || { source: null, provider: null, text: null, lines: [], plugin: [] };
}
/**
* Subscribes to lyrics for a player.
* @param {string} guildId - The ID of the guild to subscribe to lyrics for.
* @param {boolean} [skipTrackSource=false] - Whether to skip using the track's source URL.
* @returns {Promise<unknown>} A promise that resolves when the subscription is complete.
* @throws {RangeError} If the node is not connected to the lavalink server or if the java-lyrics-plugin is not available.
*/
async lyricsSubscribe(guildId, skipTrackSource = false) {
if (!this.connected) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_DISCONNECTED,
message: `The node is not connected to the lavalink server.`,
context: { identifier: this.options.identifier },
});
}
if (this.isNodeLink) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PROTOCOL_ERROR,
message: `The node is a NodeLink, cannot subscribe to lyrics.`,
context: { identifier: this.options.identifier },
});
}
const requiredPlugins = ["lavalyrics-plugin"];
for (const plugin of requiredPlugins) {
if (!this.info.plugins.some((p) => p.name === plugin)) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PLUGIN_ERROR,
message: `The plugin "${plugin}" must be present in the lavalink node.`,
context: { identifier: this.options.identifier },
});
}
}
if (!this.info.plugins.some((p) => p.name === "lavasrc-plugin" || p.name === "java-lyrics-plugin")) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PLUGIN_ERROR,
message: `One of the following plugins must also be present in the lavalink node: "lavasrc-plugin" or "java-lyrics-plugin".`,
context: { identifier: this.options.identifier },
});
}
try {
return await this.rest.post(`/v4/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe?skipTrackSource=${skipTrackSource}`, {});
}
catch (err) {
throw err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PROTOCOL_ERROR,
message: "Failed to subscribe to lyrics session.",
cause: err instanceof Error ? err : undefined,
context: { identifier: this.options.identifier, guildId, skipTrackSource },
});
}
}
/**
* Unsubscribes from lyrics for a player.
* @param {string} guildId - The ID of the guild to unsubscribe from lyrics for.
* @returns {Promise<unknown>} A promise that resolves when the unsubscription is complete.
* @throws {RangeError} If the node is not connected to the lavalink server or if the java-lyrics-plugin is not available.
*/
async lyricsUnsubscribe(guildId) {
if (!this.connected) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_DISCONNECTED,
message: `The node is not connected to the lavalink server.`,
context: { identifier: this.options.identifier },
});
}
if (this.isNodeLink) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PROTOCOL_ERROR,
message: `The node is a NodeLink, cannot unsubscribe from lyrics.`,
context: { identifier: this.options.identifier },
});
}
if (!this.info.plugins.some((plugin) => plugin.name === "java-lyrics-plugin")) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PLUGIN_ERROR,
message: `The plugin "java-lyrics-plugin" must be present in the lavalink node to unsubscribe.`,
context: { identifier: this.options.identifier },
});
}
try {
return await this.rest.delete(`/v4/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe`);
}
catch (err) {
throw err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PROTOCOL_ERROR,
message: "Failed to unsubscribe from lyrics session.",
cause: err instanceof Error ? err : undefined,
context: { identifier: this.options.identifier, guildId },
});
}
}
/**
* Handles the event when a track becomes stuck during playback.
* Stops the current track and emits a `trackStuck` event.
*
* @param {Player} player - The player associated with the track that became stuck.
* @param {Track} track - The track that became stuck.
* @param {TrackStuckEvent} payload - The event payload containing additional data about the track stuck event.
* @returns {void}
* @protected
*/
async trackStuck(player, track, payload) {
await player.stop();
this.manager.emit(Enums_1.ManagerEventTypes.TrackStuck, player, track, payload);
}
/**
* Handles the event when a track has an error during playback.
* Stops the current track and emits a `trackError` event.
*
* @param {Player} player - The player associated with the track that had an error.
* @param {Track} track - The track that had an error.
* @param {TrackExceptionEvent} payload - The event payload containing additional data about the track error event.
* @returns {void}
* @protected
*/
async trackError(player, track, payload) {
await player.stop();
this.manager.emit(Enums_1.ManagerEventTypes.TrackError, player, track, payload);
}
/**
* Emitted when the WebSocket connection for a player closes.
* The payload of the event will contain the close code and reason if provided.
* @param {Player} player - The player associated with the WebSocket connection.
* @param {WebSocketClosedEvent} payload - The event payload containing additional data about the WebSocket close event.
*/
socketClosed(player, payload) {
this.manager.emit(Enums_1.ManagerEventTypes.SocketClosed, player, payload);
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Websocket closed for player: ${player.guildId} with payload: ${Utils_1.JSONUtils.safe(payload, 2)}`);
}
/**
* Emitted when the segments for a track are loaded.
* The payload of the event will contain the segments.
* @param {Player} player - The player associated with the segments.
* @param {Track} track - The track associated with the segments.
* @param {SponsorBlockSegmentsLoaded} payload - The event payload containing additional data about the segments loaded event.
*/
sponsorBlockSegmentLoaded(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.SegmentsLoaded, player, track, payload);
}
/**
* Emitted when a segment of a track is skipped using the sponsorblock plugin.
* The payload of the event will contain the skipped segment.
* @param {Player} player - The player associated with the skipped segment.
* @param {Track} track - The track associated with the skipped segment.
* @param {SponsorBlockSegmentSkipped} payload - The event payload containing additional data about the segment skipped event.
*/
sponsorBlockSegmentSkipped(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.SegmentSkipped, player, track, payload);
}
/**
* Emitted when chapters for a track are loaded using the sponsorblock plugin.
* The payload of the event will contain the chapters.
* @param {Player} player - The player associated with the chapters.
* @param {Track} track - The track associated with the chapters.
* @param {SponsorBlockChaptersLoaded} payload - The event payload containing additional data about the chapters loaded event.
*/
sponsorBlockChaptersLoaded(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.ChaptersLoaded, player, track, payload);
}
/**
* Emitted when a chapter of a track is started using the sponsorblock plugin.
* The payload of the event will contain the started chapter.
* @param {Player} player - The player associated with the started chapter.
* @param {Track} track - The track associated with the started chapter.
* @param {SponsorBlockChapterStarted} payload - The event payload containing additional data about the chapter started event.
*/
sponsorBlockChapterStarted(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.ChapterStarted, player, track, payload);
}
/**
* Emitted when lyrics for a track are found.
* The payload of the event will contain the lyrics.
* @param {Player} player - The player associated with the lyrics.
* @param {Track} track - The track associated with the lyrics.
* @param {LyricsFoundEvent} payload - The event payload containing additional data about the lyrics found event.
*/
lyricsFound(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.LyricsFound, player, track, payload);
}
/**
* Emitted when lyrics for a track are not found.
* The payload of the event will contain the track.
* @param {Player} player - The player associated with the lyrics.
* @param {Track} track - The track associated with the lyrics.
* @param {LyricsNotFoundEvent} payload - The event payload containing additional data about the lyrics not found event.
*/
lyricsNotFound(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.LyricsNotFound, player, track, payload);
}
/**
* Emitted when a line of lyrics for a track is received.
* The payload of the event will contain the lyrics line.
* @param {Player} player - The player associated with the lyrics line.
* @param {Track} track - The track associated with the lyrics line.
* @param {LyricsLineEvent} payload - The event payload containing additional data about the lyrics line event.
*/
lyricsLine(player, track, payload) {
return this.manager.emit(Enums_1.ManagerEventTypes.LyricsLine, player, track, payload);
}
/**
* Fetches Lavalink node information.
* @returns {Promise<LavalinkInfo>} A promise that resolves to the Lavalink node information.
*/
async fetchInfo() {
return (await this.rest.get(`/v4/info`));
}
/**
* Gets the current sponsorblock segments for a player.
* @param {Player} player - The player to get the sponsorblocks for.
* @returns {Promise<SponsorBlockSegment[]>} A promise that resolves to the sponsorblock segments.
* @throws {RangeError} If the sponsorblock-plugin is not available in the Lavalink node.
*/