UNPKG

magmastream

Version:

A user-friendly Lavalink client designed for NodeJS.

1,031 lines 52.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Node = exports.SponsorBlockSegment = void 0; const tslib_1 = require("tslib"); const Utils_1 = require("./Utils"); const Manager_1 = require("./Manager"); 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 axios_1 = tslib_1.__importDefault(require("axios")); var SponsorBlockSegment; (function (SponsorBlockSegment) { SponsorBlockSegment["Sponsor"] = "sponsor"; SponsorBlockSegment["SelfPromo"] = "selfpromo"; SponsorBlockSegment["Interaction"] = "interaction"; SponsorBlockSegment["Intro"] = "intro"; SponsorBlockSegment["Outro"] = "outro"; SponsorBlockSegment["Preview"] = "preview"; SponsorBlockSegment["MusicOfftopic"] = "music_offtopic"; SponsorBlockSegment["Filler"] = "filler"; })(SponsorBlockSegment || (exports.SponsorBlockSegment = SponsorBlockSegment = {})); const validSponsorBlocks = Object.values(SponsorBlockSegment).map((v) => v.toLowerCase()); const sessionIdsFilePath = path_1.default.join(process.cwd(), "magmastream", "dist", "sessionData", "sessionIds.json"); let sessionIdsMap = new Map(); const configDir = path_1.default.dirname(sessionIdsFilePath); if (!fs_1.default.existsSync(configDir)) { fs_1.default.mkdirSync(configDir, { recursive: true }); } class Node { options; /** The socket for the node. */ socket = null; /** The stats for the node. */ stats; /** The manager for the node */ manager; /** The node's session ID. */ sessionId; /** The REST instance. */ rest; /** Actual Lavalink information of the node. */ info = null; static _manager; reconnectTimeout; reconnectAttempts = 1; /** * Creates an instance of Node. * @param options */ constructor(options) { this.options = options; if (!this.manager) this.manager = Utils_1.Structure.get("Node")._manager; if (!this.manager) throw new RangeError("Manager has not been initiated."); 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 = { port: 2333, password: "youshallnotpass", secure: false, retryAmount: 30, retryDelay: 60000, priority: 0, ...options, }; if (this.options.secure) { this.options.port = 443; } this.options.identifier = options.identifier || options.host; 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(Manager_1.ManagerEventTypes.NodeCreate, this); this.rest = new Rest_1.Rest(this, this.manager); this.createSessionIdsFile(); this.loadSessionIds(); // Create README file to inform the user about the magmastream folder this.createReadmeFile(); } /** Returns if connected to the Node. */ get connected() { if (!this.socket) return false; return this.socket.readyState === ws_1.default.OPEN; } /** Returns the address for this node. */ get address() { return `${this.options.host}:${this.options.port}`; } /** @hidden */ static init(manager) { this._manager = manager; } /** * Creates the sessionIds.json file if it doesn't exist. This file is used to * store the session IDs for each node. The session IDs are used to identify * the node when resuming a session. */ createSessionIdsFile() { // If the sessionIds.json file does not exist, create it if (!fs_1.default.existsSync(sessionIdsFilePath)) { this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Creating sessionId file at: ${sessionIdsFilePath}`); // Create the file with an empty object as the content fs_1.default.writeFileSync(sessionIdsFilePath, JSON.stringify({}), "utf-8"); } } /** * 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. */ loadSessionIds() { // Check if the sessionIds.json file exists if (fs_1.default.existsSync(sessionIdsFilePath)) { // Emit a debug event indicating that session IDs are being loaded this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Loading sessionIds from file: ${sessionIdsFilePath}`); // Read the content of the sessionIds.json file as a string const sessionIdsData = fs_1.default.readFileSync(sessionIdsFilePath, "utf-8"); // Parse the JSON string into an object and convert it into a Map sessionIdsMap = new Map(Object.entries(JSON.parse(sessionIdsData))); // Check if the session IDs Map contains the session ID for this node const compositeKey = `${this.options.identifier}::${this.manager.options.clusterId}`; if (sessionIdsMap.has(compositeKey)) { // Set the session ID on this node if it exists in the session IDs Map this.sessionId = sessionIdsMap.get(compositeKey); } } } /** * 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. */ updateSessionId() { // Emit a debug event indicating that the session IDs are being updated this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Updating sessionIds to file: ${sessionIdsFilePath}`); // Create a composite key using identifier and clusterId const compositeKey = `${this.options.identifier}::${this.manager.options.clusterId}`; // Update the session IDs Map with the new session ID sessionIdsMap.set(compositeKey, this.sessionId); // Write the updated session IDs Map to the sessionIds.json file fs_1.default.writeFileSync(sessionIdsFilePath, JSON.stringify(Object.fromEntries(sessionIdsMap))); } /** * 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 `resumeStatus` option is true, it will use the session ID * stored in the sessionIds.json file if it exists. */ connect() { if (this.connected) return; const headers = { Authorization: this.options.password, "User-Id": this.manager.options.clientId, "Client-Name": this.manager.options.clientName, }; const compositeKey = `${this.options.identifier}::${this.manager.options.clusterId}`; if (this.sessionId) { headers["Session-Id"] = this.sessionId; } else if (this.options.resumeStatus && sessionIdsMap.has(compositeKey)) { this.sessionId = sessionIdsMap.get(compositeKey) || null; headers["Session-Id"] = this.sessionId; } this.socket = new ws_1.default(`ws${this.options.secure ? "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("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, secure: this.options.secure, identifier: this.options.identifier, }, }; this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Connecting ${JSON.stringify(debugInfo)}`); } /** * 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; // Emit a debug event indicating that the node is being destroyed 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(Manager_1.ManagerEventTypes.Debug, `[NODE] Destroying node: ${JSON.stringify(debugInfo)}`); // Automove all players connected to that node const players = this.manager.players.filter((p) => p.node == this); if (players.size) { players.forEach(async (player) => { await player.autoMoveNode(); }); } // Close the WebSocket connection this.socket.close(1000, "destroy"); // Remove all event listeners on the WebSocket this.socket.removeAllListeners(); // Clear the reconnect timeout this.reconnectAttempts = 1; clearTimeout(this.reconnectTimeout); // Emit a "nodeDestroy" event with the node as the argument this.manager.emit(Manager_1.ManagerEventTypes.NodeDestroy, this); // Destroy the node from the manager await this.manager.destroyNode(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() { // Collect debug information regarding the current state of the node const debugInfo = { identifier: this.options.identifier, connected: this.connected, reconnectAttempts: this.reconnectAttempts, retryAmount: this.options.retryAmount, retryDelay: this.options.retryDelay, }; // Emit a debug event indicating the node is attempting to reconnect this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Reconnecting node: ${JSON.stringify(debugInfo)}`); // Schedule the reconnection attempt after the specified retry delay this.reconnectTimeout = setTimeout(async () => { // Check if the maximum number of retry attempts has been reached if (this.reconnectAttempts >= this.options.retryAmount) { // Emit an error event and destroy the node if retries are exhausted const error = new Error(`Unable to connect after ${this.options.retryAmount} attempts.`); this.manager.emit(Manager_1.ManagerEventTypes.NodeError, this, error); return await this.destroy(); } // Remove all listeners from the current WebSocket and reset it this.socket?.removeAllListeners(); this.socket = null; // Emit a nodeReconnect event and attempt to connect again this.manager.emit(Manager_1.ManagerEventTypes.NodeReconnect, this); this.connect(); // Increment the reconnect attempts counter this.reconnectAttempts++; }, this.options.retryDelay); } /** * 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() { // Clear any existing reconnect timeouts if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); // Collect debug information regarding the current state of the node const debugInfo = { identifier: this.options.identifier, connected: this.connected, }; // Emit a debug event indicating the node is connected this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Connected node: ${JSON.stringify(debugInfo)}`); // Emit a "nodeConnect" event with the node as the argument this.manager.emit(Manager_1.ManagerEventTypes.NodeConnect, this); } /** * 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, }; // Emit a "nodeDisconnect" event with the node and the close event as arguments this.manager.emit(Manager_1.ManagerEventTypes.NodeDisconnect, this, { code, reason }); // Emit a debug event indicating the node is disconnected this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Disconnected node: ${JSON.stringify(debugInfo)}`); // Try moving all players connected to that node to a useable one 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())); } } // If the close event was not initiated by the user, attempt to reconnect 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; // Collect debug information regarding the error const debugInfo = { identifier: this.options.identifier, error: error.message, }; // Emit a debug event indicating the error on the node this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Error on node: ${JSON.stringify(debugInfo)}`); // Emit a "nodeError" event with the node and the error as arguments this.manager.emit(Manager_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(Manager_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(Manager_1.ManagerEventTypes.Debug, `[NODE] Node message: ${JSON.stringify(payload)}`); await this.handleEvent(payload); break; case "ready": this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Node message: ${JSON.stringify(payload)}`); this.rest.setSessionId(payload.sessionId); this.sessionId = payload.sessionId; this.updateSessionId(); // Call to update session ID this.info = await this.fetchInfo(); // Log if the session was resumed successfully if (payload.resumed) { // Load player states from the JSON file await this.manager.loadPlayerStates(this.options.identifier); } if (this.options.resumeStatus) { await this.rest.patch(`/v4/sessions/${this.sessionId}`, { resuming: this.options.resumeStatus, timeout: this.options.resumeTimeout, }); } break; default: this.manager.emit(Manager_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 = player.queue.current; const type = payload.type; let error; switch (type) { case "TrackStartEvent": this.trackStart(player, track, payload); break; case "TrackEndEvent": if (player?.nowPlayingMessage && player?.nowPlayingMessage.deletable) { await player?.nowPlayingMessage?.delete().catch(() => { }); } 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, player.queue.current, payload); break; case "SegmentSkipped": this.sponsorBlockSegmentSkipped(player, player.queue.current, payload); break; case "ChaptersLoaded": this.sponsorBlockChaptersLoaded(player, player.queue.current, payload); break; case "ChapterStarted": this.sponsorBlockChapterStarted(player, player.queue.current, payload); break; default: error = new Error(`Node#event unknown event '${type}'.`); this.manager.emit(Manager_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(Manager_1.ManagerEventTypes.TrackStart, player, track, payload); const botUser = player.get("Internal_BotUser"); if (botUser && botUser.id === track.requester.id) { this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, player, { changeType: Manager_1.PlayerStateEventTypes.TrackChange, details: { changeType: "autoPlay", track: track, }, }); return; } this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, player, { changeType: Manager_1.PlayerStateEventTypes.TrackChange, details: { changeType: "start", track: 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"); if (!skipFlag && (player.queue.previous.length === 0 || (player.queue.previous[0] && player.queue.previous[0].track !== player.queue.current?.track))) { // Store the current track in the previous tracks queue player.queue.previous.push(player.queue.current); // Limit the previous tracks queue to maxPreviousTracks if (player.queue.previous.length > this.manager.options.maxPreviousTracks) { player.queue.previous.shift(); } } const oldPlayer = player; // Handle track end events switch (reason) { case Utils_1.TrackEndReasonTypes.LoadFailed: case Utils_1.TrackEndReasonTypes.Cleanup: // Handle the case when a track failed to load or was cleaned up await this.handleFailedTrack(player, track, payload); break; case Utils_1.TrackEndReasonTypes.Replaced: // Handle the case when a track was replaced break; case Utils_1.TrackEndReasonTypes.Stopped: // If the track was forcibly replaced if (player.queue.length) { await this.playNextTrack(player, track, payload); } else { await this.queueEnd(player, track, payload); } break; case Utils_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 (player.queue.length) { await this.playNextTrack(player, track, payload); } else { await this.queueEnd(player, track, payload); } break; default: this.manager.emit(Manager_1.ManagerEventTypes.NodeError, this, new Error(`Unexpected track end reason "${reason}"`)); break; } this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, player, { changeType: Manager_1.PlayerStateEventTypes.TrackChange, details: { changeType: "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 || !player.queue.previous.length) return false; // Get the Last.fm API key and the available source managers const apiKey = this.manager.options.lastFmApiKey; const enabledSources = this.info.sourceManagers; // Determine if YouTube should be used // If Last.fm is not available, use YouTube as a fallback // If YouTube is available and this is the last attempt, use YouTube const shouldUseYouTube = (!apiKey && enabledSources.includes("youtube")) || // Fallback to YouTube if Last.fm is not available (attempt === player.autoplayTries - 1 && player.autoplayTries > 1 && enabledSources.includes("youtube")); // Use YouTube on the last attempt const lastTrack = player.queue.previous[player.queue.previous.length - 1]; if (shouldUseYouTube) { // Use YouTube-based autoplay return await this.handleYouTubeAutoplay(player, lastTrack); } // Handle Last.fm-based autoplay (or other platforms) const selectedSource = this.selectPlatform(enabledSources); if (selectedSource) { // Use the selected source to handle autoplay return await this.handlePlatformAutoplay(player, lastTrack, selectedSource, apiKey); } // If no source is available, return false return false; } /** * Selects a platform from the given enabled sources. * @param {string[]} enabledSources - The enabled sources to select from. * @returns {SearchPlatform | null} - The selected platform or null if none was found. */ selectPlatform(enabledSources) { const { autoPlaySearchPlatform } = this.manager.options; const platformMapping = { [Manager_1.SearchPlatform.AppleMusic]: "applemusic", [Manager_1.SearchPlatform.Bandcamp]: "bandcamp", [Manager_1.SearchPlatform.Deezer]: "deezer", [Manager_1.SearchPlatform.Jiosaavn]: "jiosaavn", [Manager_1.SearchPlatform.SoundCloud]: "soundcloud", [Manager_1.SearchPlatform.Spotify]: "spotify", [Manager_1.SearchPlatform.Tidal]: "tidal", [Manager_1.SearchPlatform.VKMusic]: "vkmusic", [Manager_1.SearchPlatform.YouTube]: "youtube", [Manager_1.SearchPlatform.YouTubeMusic]: "youtube", }; // Try the autoPlaySearchPlatform first if (enabledSources.includes(platformMapping[autoPlaySearchPlatform])) { return autoPlaySearchPlatform; } // Fallback to other platforms in a predefined order const fallbackPlatforms = [ Manager_1.SearchPlatform.Spotify, Manager_1.SearchPlatform.Deezer, Manager_1.SearchPlatform.SoundCloud, Manager_1.SearchPlatform.AppleMusic, Manager_1.SearchPlatform.Bandcamp, Manager_1.SearchPlatform.Jiosaavn, Manager_1.SearchPlatform.Tidal, Manager_1.SearchPlatform.VKMusic, Manager_1.SearchPlatform.YouTubeMusic, Manager_1.SearchPlatform.YouTube, ]; for (const platform of fallbackPlatforms) { if (enabledSources.includes(platformMapping[platform])) { return platform; } } return null; } /** * Handles Last.fm-based autoplay. * @param {Player} player - The player instance. * @param {Track} previousTrack - The previous track. * @param {SearchPlatform} platform - The selected platform. * @param {string} apiKey - The Last.fm API key. * @returns {Promise<boolean>} - Whether the autoplay was successful. */ async handlePlatformAutoplay(player, previousTrack, platform, apiKey) { let { author: artist } = previousTrack; const { title } = previousTrack; if (!artist || !title) { if (!title) { // No title provided, search for the artist's top tracks const noTitleUrl = `https://ws.audioscrobbler.com/2.0/?method=artist.getTopTracks&artist=${artist}&autocorrect=1&api_key=${apiKey}&format=json`; const response = await axios_1.default.get(noTitleUrl); if (response.data.error || !response.data.toptracks?.track?.length) return false; const randomTrack = response.data.toptracks.track[Math.floor(Math.random() * response.data.toptracks.track.length)]; const res = await player.search({ query: `${randomTrack.artist.name} - ${randomTrack.name}`, source: platform }, player.get("Internal_BotUser")); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return false; const foundTrack = res.tracks.find((t) => t.uri !== previousTrack.uri); if (!foundTrack) return false; player.queue.add(foundTrack); await player.play(); return true; } if (!artist) { // No artist provided, search for the track title const noArtistUrl = `https://ws.audioscrobbler.com/2.0/?method=track.search&track=${title}&api_key=${apiKey}&format=json`; const response = await axios_1.default.get(noArtistUrl); artist = response.data.results.trackmatches?.track?.[0]?.artist; if (!artist) return false; } } // Search for similar tracks to the current track const url = `https://ws.audioscrobbler.com/2.0/?method=track.getSimilar&artist=${artist}&track=${title}&limit=10&autocorrect=1&api_key=${apiKey}&format=json`; let response; try { response = await axios_1.default.get(url); } catch (error) { if (error) return false; } if (response.data.error || !response.data.similartracks?.track?.length) { // Retry the request if the first attempt fails const retryUrl = `https://ws.audioscrobbler.com/2.0/?method=artist.getTopTracks&artist=${artist}&autocorrect=1&api_key=${apiKey}&format=json`; const retryResponse = await axios_1.default.get(retryUrl); if (retryResponse.data.error || !retryResponse.data.toptracks?.track?.length) return false; const randomTrack = retryResponse.data.toptracks.track[Math.floor(Math.random() * retryResponse.data.toptracks.track.length)]; const res = await player.search({ query: `${randomTrack.artist.name} - ${randomTrack.name}`, source: platform }, player.get("Internal_BotUser")); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return false; const foundTrack = res.tracks.find((t) => t.uri !== previousTrack.uri); if (!foundTrack) return false; player.queue.add(foundTrack); await player.play(); return true; } const randomTrack = response.data.similartracks.track[Math.floor(Math.random() * response.data.similartracks.track.length)]; const res = await player.search({ query: `${randomTrack.artist.name} - ${randomTrack.name}`, source: platform }, player.get("Internal_BotUser")); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return false; const foundTrack = res.tracks.find((t) => t.uri !== previousTrack.uri); if (!foundTrack) return false; player.queue.add(foundTrack); await player.play(); return true; } /** * Handles YouTube-based autoplay. * @param {Player} player - The player instance. * @param {Track} previousTrack - The previous track. * @returns {Promise<boolean>} - Whether the autoplay was successful. */ async handleYouTubeAutoplay(player, previousTrack) { // Check if the previous track has a YouTube URL const hasYouTubeURL = ["youtube.com", "youtu.be"].some((url) => previousTrack.uri.includes(url)); // Get the video ID from the previous track's URL const videoID = hasYouTubeURL ? previousTrack.uri.split("=").pop() : (await this.manager.search({ query: `${previousTrack.author} - ${previousTrack.title}`, source: Manager_1.SearchPlatform.YouTube }, player.get("Internal_BotUser"))).tracks[0]?.uri .split("=") .pop(); // If the video ID is not found, return false if (!videoID) return false; // Get a random video index between 2 and 24 let randomIndex; let searchURI; do { // Generate a random index between 2 and 24 randomIndex = Math.floor(Math.random() * 23) + 2; // Build the search URI searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`; } while (previousTrack.uri.includes(searchURI)); // Search for the video and return false if the search fails const res = await this.manager.search({ query: searchURI, source: Manager_1.SearchPlatform.YouTube }, player.get("Internal_BotUser")); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return false; // Find a track that is not the same as the current track const foundTrack = res.tracks.find((t) => t.uri !== previousTrack.uri && t.author !== previousTrack.author && t.title !== previousTrack.title); // If no track is found, return false if (!foundTrack) return false; // Add the found track to the queue and play it player.queue.add(foundTrack); await player.play(); return true; } /** * 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) { player.queue.current = player.queue.shift(); if (!player.queue.current) { await this.queueEnd(player, track, payload); return; } this.manager.emit(Manager_1.ManagerEventTypes.TrackEnd, player, track, payload); if (this.manager.options.autoPlay) 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 { autoPlay } = this.manager.options; if (trackRepeat) { // Prevent duplicate repeat insertion if (queue[0] !== queue.current) { queue.unshift(queue.current); } } else if (queueRepeat) { // Prevent duplicate queue insertion if (queue[queue.length - 1] !== queue.current) { queue.add(queue.current); } } // Move to the next track queue.current = queue.shift(); // Emit track end event this.manager.emit(Manager_1.ManagerEventTypes.TrackEnd, player, track, payload); // If the track was stopped manually and no more tracks exist, end the queue if (payload.reason === Utils_1.TrackEndReasonTypes.Stopped && !(queue.current = queue.shift())) { await this.queueEnd(player, track, payload); return; } // If autoplay is enabled, play the next track if (autoPlay) 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 player.queue.current = player.queue.shift(); // Emit the track end event this.manager.emit(Manager_1.ManagerEventTypes.TrackEnd, player, track, payload); // If autoplay is enabled, play the next track if (this.manager.options.autoPlay) 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) { player.queue.current = null; if (!player.isAutoplay) { player.playing = false; this.manager.emit(Manager_1.ManagerEventTypes.QueueEnd, player, track, payload); return; } let attempts = 1; let success = false; while (attempts <= player.autoplayTries) { success = await this.handleAutoplay(player, attempts); if (success) return; attempts++; } // If all attempts fail, reset the player state and emit queueEnd player.playing = false; this.manager.emit(Manager_1.ManagerEventTypes.QueueEnd, player, track, payload); } /** * Fetches the lyrics of a track from the Lavalink node. * This method uses the `lavalyrics-plugin` to fetch the lyrics. * If the plugin is not available, it will throw a RangeError. * * @param {Track} track - The track to fetch the lyrics for. * @param {boolean} [skipTrackSource=false] - Whether to skip using the track's source URL. * @returns {Promise<Lyrics>} A promise that resolves with the lyrics data. */ async getLyrics(track, skipTrackSource = false) { if (!this.info.plugins.some((plugin) => plugin.name === "lavalyrics-plugin")) throw new RangeError(`there is no lavalyrics-plugin available in the lavalink node: ${this.options.identifier}`); // Make a GET request to the Lavalink node to fetch the lyrics // The request includes the track URL and the skipTrackSource parameter return ((await this.rest.get(`/v4/lyrics?track=${encodeURIComponent(track.track)}&skipTrackSource=${skipTrackSource}`)) || { source: null, provider: null, text: null, lines: [], plugin: [], }); } /** * 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(Manager_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(Manager_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(Manager_1.ManagerEventTypes.SocketClosed, player, payload); this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[NODE] Websocket closed for player: ${player.guildId} with payload: ${JSON.stringify(payload)}`); } /** * 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(Manager_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(Manager_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(Manager_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(Manager_1.ManagerEventTypes.ChapterStarted, 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. */ async getSponsorBlock(player) { if (!this.info.plugins.some((plugin) => plugin.name === "sponsorblock-plugin")) throw new RangeError(`there is no sponsorblock-plugin available in the lavalink node: ${this.options.identifier}`); return (await this.rest.get(`/v4/sessions/${this.sessionId}/players/${player.guildId}/sponsorblock/categories`)); } /** * Sets the sponsorblock segments for a player. * @param {Player} player - The player to set the sponsor blocks for. * @param {SponsorBlockSegment[]} segments - The sponsorblock segments to set. Defaults to `[SponsorBlockSegment.Sponsor, SponsorBlockSegment.SelfPromo]` if not provided. * @returns {Promise<void>} The promise is resolved when the operation is complete. * @throws {RangeError} If the sponsorblock-plugin is not available in the Lavalink node. * @throws {RangeError} If no segments are provided. * @throws {SyntaxError} If an invalid sponsorblock is provided. * @example * ```ts * // use it on the player via player.setSponsorBlock(); * player.setSponsorBlock([Sponso