UNPKG

magmastream

Version:

A user-friendly Lavalink client designed for NodeJS.

1,068 lines 56.6 kB
"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. */