UNPKG

lavalink-client

Version:

Easy, flexible and feature-rich lavalink@v4 Client. Both for Beginners and Proficients.

1,010 lines (1,009 loc) 81.7 kB
import { isAbsolute } from "path"; import WebSocket from "ws"; import { DebugEvents, DestroyReasons, validSponsorBlocks } from "./Constants.js"; import { NodeSymbol, queueTrackEnd } from "./Utils.js"; /** * Lavalink Node creator class */ export class LavalinkNode { heartBeatPingTimestamp = 0; heartBeatPongTimestamp = 0; get heartBeatPing() { return this.heartBeatPongTimestamp - this.heartBeatPingTimestamp; } heartBeatInterval; pingTimeout; isAlive = false; /** The provided Options of the Node */ options; /** The amount of rest calls the node has made. */ calls = 0; /** Stats from lavalink, will be updated via an interval by lavalink. */ stats = { players: 0, playingPlayers: 0, cpu: { cores: 0, lavalinkLoad: 0, systemLoad: 0 }, memory: { allocated: 0, free: 0, reservable: 0, used: 0, }, uptime: 0, frameStats: { deficit: 0, nulled: 0, sent: 0, } }; /** The current sessionId, only present when connected */ sessionId = null; /** Wether the node resuming is enabled or not */ resuming = { enabled: true, timeout: null }; /** Actual Lavalink Information of the Node */ info = null; /** The Node Manager of this Node */ NodeManager = null; /** The Reconnection Timeout */ reconnectTimeout = undefined; /** The Reconnection Attempt counter */ reconnectAttempts = 1; /** The Socket of the Lavalink */ socket = null; /** Version of what the Lavalink Server should be */ version = "v4"; /** * Create a new Node * @param options Lavalink Node Options * @param manager Node Manager * * * @example * ```ts * // don't create a node manually, instead use: * * client.lavalink.nodeManager.createNode(options) * ``` */ constructor(options, manager) { this.options = { secure: false, retryAmount: 5, retryDelay: 10e3, requestSignalTimeoutMS: 10000, heartBeatInterval: 30_000, closeOnError: true, enablePingOnStatsCheck: true, ...options }; this.NodeManager = manager; this.validate(); if (this.options.secure && this.options.port !== 443) throw new SyntaxError("If secure is true, then the port must be 443"); this.options.regions = (this.options.regions || []).map(a => a.toLowerCase()); Object.defineProperty(this, NodeSymbol, { configurable: true, value: true }); } /** * Raw Request util function * @param endpoint endpoint string * @param modify modify the request * @param extraQueryUrlParams UrlSearchParams to use in a encodedURI, useful for example for flowertts * @returns object containing request and option information * * @example * ```ts * player.node.rawRequest(`/loadtracks?identifier=Never gonna give you up`, (options) => options.method = "GET"); * ``` */ async rawRequest(endpoint, modify) { const options = { path: `/${this.version}/${endpoint.startsWith("/") ? endpoint.slice(1) : endpoint}`, method: "GET", headers: { "Authorization": this.options.authorization }, signal: this.options.requestSignalTimeoutMS && this.options.requestSignalTimeoutMS > 0 ? AbortSignal.timeout(this.options.requestSignalTimeoutMS) : undefined, }; modify?.(options); const url = new URL(`${this.restAddress}${options.path}`); url.searchParams.append("trace", "true"); if (options.extraQueryUrlParams && options.extraQueryUrlParams?.size > 0) { for (const [paramKey, paramValue] of options.extraQueryUrlParams.entries()) { url.searchParams.append(paramKey, paramValue); } } const urlToUse = url.toString(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { path, extraQueryUrlParams, ...fetchOptions } = options; // destructure fetch only options const response = await fetch(urlToUse, fetchOptions); this.calls++; return { response, options: options }; } async request(endpoint, modify, parseAsText) { if (!this.connected) throw new Error("The node is not connected to the Lavalink Server!, Please call node.connect() first!"); const { response, options } = await this.rawRequest(endpoint, modify); if (["DELETE", "PUT"].includes(options.method)) return; if (response.status === 404) throw new Error(`Node Request resulted into an error, request-PATH: ${options.path} | headers: ${JSON.stringify(response.headers)}`); return parseAsText ? await response.text() : await response.json(); } /** * Search something raw on the node, please note only add tracks to players of that node * @param query SearchQuery Object * @param requestUser Request User for creating the player(s) * @param throwOnEmpty Wether to throw on an empty result or not * @returns Searchresult * * @example * ```ts * // use player.search() instead * player.node.search({ query: "Never gonna give you up by Rick Astley", source: "soundcloud" }, interaction.user); * player.node.search({ query: "https://deezer.com/track/123456789" }, interaction.user); * ``` */ async search(query, requestUser, throwOnEmpty = false) { const Query = this.NodeManager.LavalinkManager.utils.transformQuery(query); this.NodeManager.LavalinkManager.utils.validateQueryString(this, Query.query, Query.source); if (Query.source) this.NodeManager.LavalinkManager.utils.validateSourceString(this, Query.source); if (["bcsearch", "bandcamp"].includes(Query.source) && !this.info.sourceManagers.includes("bandcamp")) { throw new Error("Bandcamp Search only works on the player (lavaplayer version < 2.2.0!"); } const requestUrl = new URL(`${this.restAddress}/loadtracks`); if (/^https?:\/\//.test(Query.query) || ["http", "https", "link", "uri"].includes(Query.source)) { // if it's a link simply encode it requestUrl.searchParams.append("identifier", Query.query); } else { // if not make a query out of it const fttsPrefix = Query.source === "ftts" ? "//" : ""; const prefix = Query.source !== "local" ? `${Query.source}:${fttsPrefix}` : ""; requestUrl.searchParams.append("identifier", `${prefix}${Query.query}`); } const requestPathAndSearch = requestUrl.pathname + requestUrl.search; const res = await this.request(requestPathAndSearch, (options) => { if (typeof query === "object" && typeof query.extraQueryUrlParams?.size === "number" && query.extraQueryUrlParams?.size > 0) { options.extraQueryUrlParams = query.extraQueryUrlParams; } }); // transform the data which can be Error, Track or Track[] to enfore [Track] const resTracks = res.loadType === "playlist" ? res.data?.tracks : res.loadType === "track" ? [res.data] : res.loadType === "search" ? Array.isArray(res.data) ? res.data : [res.data] : []; if (throwOnEmpty === true && (res.loadType === "empty" || !resTracks.length)) { if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.SearchNothingFound, { state: "warn", message: `Search found nothing for Request: "${Query.source ? `${Query.source}:` : ""}${Query.query}"`, functionLayer: "(LavalinkNode > node | player) > search()", }); } throw new Error("Nothing found"); } return { loadType: res.loadType, exception: res.loadType === "error" ? res.data : null, pluginInfo: res.pluginInfo || {}, playlist: res.loadType === "playlist" ? { name: res.data.info?.name || res.data.pluginInfo?.name || null, title: res.data.info?.name || res.data.pluginInfo?.name || null, author: res.data.info?.author || res.data.pluginInfo?.author || null, thumbnail: (res.data.info?.artworkUrl) || (res.data.pluginInfo?.artworkUrl) || ((typeof res.data?.info?.selectedTrack !== "number" || res.data?.info?.selectedTrack === -1) ? null : resTracks[res.data?.info?.selectedTrack] ? (resTracks[res.data?.info?.selectedTrack]?.info?.artworkUrl || resTracks[res.data?.info?.selectedTrack]?.info?.pluginInfo?.artworkUrl) : null) || null, uri: res.data.info?.url || res.data.info?.uri || res.data.info?.link || res.data.pluginInfo?.url || res.data.pluginInfo?.uri || res.data.pluginInfo?.link || null, selectedTrack: typeof res.data?.info?.selectedTrack !== "number" || res.data?.info?.selectedTrack === -1 ? null : resTracks[res.data?.info?.selectedTrack] ? this.NodeManager.LavalinkManager.utils.buildTrack(resTracks[res.data?.info?.selectedTrack], requestUser) : null, duration: resTracks.length ? resTracks.reduce((acc, cur) => acc + (cur?.info?.duration || cur?.info?.length || 0), 0) : 0, } : null, tracks: (resTracks.length ? resTracks.map(t => this.NodeManager.LavalinkManager.utils.buildTrack(t, requestUser)) : []) }; } /** * Search something using the lavaSearchPlugin (filtered searches by types) * @param query LavaSearchQuery Object * @param requestUser Request User for creating the player(s) * @param throwOnEmpty Wether to throw on an empty result or not * @returns LavaSearchresult (SearchResult if link is provided) * * @example * ```ts * // use player.search() instead * player.node.lavaSearch({ types: ["playlist", "album"], query: "Rick Astley", source: "spotify" }, interaction.user); * ``` */ async lavaSearch(query, requestUser, throwOnEmpty = false) { const Query = this.NodeManager.LavalinkManager.utils.transformLavaSearchQuery(query); if (Query.source) this.NodeManager.LavalinkManager.utils.validateSourceString(this, Query.source); if (/^https?:\/\//.test(Query.query)) return this.search({ query: Query.query, source: Query.source }, requestUser); if (!["spsearch", "sprec", "amsearch", "dzsearch", "dzisrc", "ytmsearch", "ytsearch"].includes(Query.source)) throw new SyntaxError(`Query.source must be a source from LavaSrc: "spsearch" | "sprec" | "amsearch" | "dzsearch" | "dzisrc" | "ytmsearch" | "ytsearch"`); if (!this.info.plugins.find(v => v.name === "lavasearch-plugin")) throw new RangeError(`there is no lavasearch-plugin available in the lavalink node: ${this.id}`); if (!this.info.plugins.find(v => v.name === "lavasrc-plugin")) throw new RangeError(`there is no lavasrc-plugin available in the lavalink node: ${this.id}`); const { response } = await this.rawRequest(`/loadsearch?query=${Query.source ? `${Query.source}:` : ""}${encodeURIComponent(Query.query)}${Query.types?.length ? `&types=${Query.types.join(",")}` : ""}`); const res = (response.status === 204 ? {} : await response.json()); if (throwOnEmpty === true && !Object.entries(res).flat().filter(Boolean).length) { if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.LavaSearchNothingFound, { state: "warn", message: `LavaSearch found nothing for Request: "${Query.source ? `${Query.source}:` : ""}${Query.query}"`, functionLayer: "(LavalinkNode > node | player) > lavaSearch()", }); } throw new Error("Nothing found"); } return { tracks: res.tracks?.map(v => this.NodeManager.LavalinkManager.utils.buildTrack(v, requestUser)) || [], albums: res.albums?.map(v => ({ info: v.info, pluginInfo: v?.plugin || v.pluginInfo, tracks: v.tracks.map(v => this.NodeManager.LavalinkManager.utils.buildTrack(v, requestUser)) })) || [], artists: res.artists?.map(v => ({ info: v.info, pluginInfo: v?.plugin || v.pluginInfo, tracks: v.tracks.map(v => this.NodeManager.LavalinkManager.utils.buildTrack(v, requestUser)) })) || [], playlists: res.playlists?.map(v => ({ info: v.info, pluginInfo: v?.plugin || v.pluginInfo, tracks: v.tracks.map(v => this.NodeManager.LavalinkManager.utils.buildTrack(v, requestUser)) })) || [], texts: res.texts?.map(v => ({ text: v.text, pluginInfo: v?.plugin || v.pluginInfo })) || [], pluginInfo: res.pluginInfo || res?.plugin }; } /** * Update the Player State on the Lavalink Server * @param data data to send to lavalink and sync locally * @returns result from lavalink * * @example * ```ts * // use player.search() instead * player.node.updatePlayer({ guildId: player.guildId, playerOptions: { paused: true } }); // example to pause it * ``` */ async updatePlayer(data) { if (!this.sessionId) throw new Error("The Lavalink Node is either not ready, or not up to date!"); this.syncPlayerData(data); const res = await this.request(`/sessions/${this.sessionId}/players/${data.guildId}`, r => { r.method = "PATCH"; r.headers["Content-Type"] = "application/json"; r.body = JSON.stringify(data.playerOptions); if (data.noReplace) { const url = new URL(`${this.restAddress}${r.path}`); url.searchParams.append("noReplace", data.noReplace === true && typeof data.noReplace === "boolean" ? "true" : "false"); r.path = url.pathname + url.search; } }); if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.PlayerUpdateSuccess, { state: "log", message: `Player get's updated with following payload :: ${JSON.stringify(data.playerOptions, null, 3)}`, functionLayer: "LavalinkNode > node > updatePlayer()", }); } this.syncPlayerData({}, res); return res; } /** * Destroys the Player on the Lavalink Server * @param guildId * @returns request result * * @example * ```ts * // use player.destroy() instead * player.node.destroyPlayer(player.guildId); * ``` */ async destroyPlayer(guildId) { if (!this.sessionId) throw new Error("The Lavalink-Node is either not ready, or not up to date!"); return this.request(`/sessions/${this.sessionId}/players/${guildId}`, r => { r.method = "DELETE"; }); } /** * Connect to the Lavalink Node * @param sessionId Provide the Session Id of the previous connection, to resume the node and it's player(s) * @returns void * * @example * ```ts * player.node.connect(); // if provided on bootup in managerOptions#nodes, this will be called automatically when doing lavalink.init() * * // or connect from a resuming session: * player.node.connect("sessionId"); * ``` */ connect(sessionId) { if (this.connected) { if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.TryingConnectWhileConnected, { state: "warn", message: `Tryed to connect to node, but it's already connected!`, functionLayer: "LavalinkNode > node > connect()", }); } return; } const headers = { Authorization: this.options.authorization, "User-Id": this.NodeManager.LavalinkManager.options.client.id, "Client-Name": this.NodeManager.LavalinkManager.options.client.username || "Lavalink-Client", }; if (typeof this.options.sessionId === "string" || typeof sessionId === "string") { headers["Session-Id"] = this.options.sessionId || sessionId; this.sessionId = this.options.sessionId || sessionId; } this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.options.host}:${this.options.port}/v4/websocket`, { headers }); this.socket.on("open", this.open.bind(this)); this.socket.on("close", (code, reason) => this.close(code, reason?.toString())); this.socket.on("message", this.message.bind(this)); this.socket.on("error", this.error.bind(this)); // this.socket.on("ping", () => this.heartBeat("ping")); // lavalink doesn'T send ping periodically, therefore we use the stats message } heartBeat() { if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.HeartBeatTriggered, { state: "log", message: `Node Socket Heartbeat triggered, resetting old Timeout to 65000ms (should happen every 60s due to /stats event)`, functionLayer: "LavalinkNode > nodeEvent > stats > heartBeat()", }); } if (this.pingTimeout) clearTimeout(this.pingTimeout); this.pingTimeout = setTimeout(() => { this.pingTimeout = null; if (!this.socket) { if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.NoSocketOnDestroy, { state: "error", message: `Heartbeat registered a disconnect, but socket didn't exist therefore can't terminate`, functionLayer: "LavalinkNode > nodeEvent > stats > heartBeat() > timeoutHit", }); } return; } if (this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents) { this.NodeManager.LavalinkManager.emit("debug", DebugEvents.SocketTerminateHeartBeatTimeout, { state: "warn", message: `Heartbeat registered a disconnect, because timeout wasn't resetted in time. Terminating Web-Socket`, functionLayer: "LavalinkNode > nodeEvent > stats > heartBeat() > timeoutHit", }); } this.isAlive = false; this.socket.terminate(); }, 65_000); // the stats endpoint get's sent every 60s. se wee add a 5s buffer to make sure we don't miss any stats message } /** * Get the id of the node * * @example * ```ts * const nodeId = player.node.id; * console.log("node id is: ", nodeId) * ``` */ get id() { return this.options.id || `${this.options.host}:${this.options.port}`; } /** * Destroys the Node-Connection (Websocket) and all player's of the node * @param destroyReason Destroy Reason to use when destroying the players * @param deleteNode wether to delete the nodte from the nodes list too, if false it will emit a disconnect. @default true * @param movePlayers whether to movePlayers to different eligible connected node. If false players won't be moved @default false * @returns void * * @example * Destroys node and its players * ```ts * player.node.destroy("custom Player Destroy Reason", true); * ``` * destroys only the node and moves its players to different connected node. * ```ts * player.node.destroy("custom Player Destroy Reason", true, true); * ``` */ destroy(destroyReason, deleteNode = true, movePlayers = false) { if (!this.connected) return; const players = this.NodeManager.LavalinkManager.players.filter(p => p.node.id === this.id); if (players.size) { const enableDebugEvents = this.NodeManager.LavalinkManager.options?.advancedOptions?.enableDebugEvents; const handlePlayerOperations = () => { if (movePlayers) { const nodeToMove = Array.from(this.NodeManager.leastUsedNodes("playingPlayers")) .find(n => n.connected && n.options.id !== this.id); if (nodeToMove) { return Promise.allSettled(Array.from(players.values()).map(player => player.changeNode(nodeToMove.options.id) .catch(error => { if (enableDebugEvents) { console.error(`Node > destroy() Failed to move player ${player.guildId}: ${error.message}`); } return player.destroy(error.message ?? DestroyReasons.PlayerChangeNodeFail) .catch(destroyError => { if (enableDebugEvents) { console.error(`Node > destroy() Failed to destroy player ${player.guildId} after move failure: ${destroyError.message}`); } }); }))); } else { return Promise.allSettled(Array.from(players.values()).map(player => player.destroy(DestroyReasons.PlayerChangeNodeFailNoEligibleNode) .catch(error => { if (enableDebugEvents) { console.error(`Node > destroy() Failed to destroy player ${player.guildId}: ${error.message}`); } }))); } } else { return Promise.allSettled(Array.from(players.values()).map(player => player.destroy(destroyReason || DestroyReasons.NodeDestroy) .catch(error => { if (enableDebugEvents) { console.error(`Node > destroy() Failed to destroy player ${player.guildId}: ${error.message}`); } }))); } }; // Handle all player operations first, then clean up the socket handlePlayerOperations().finally(() => { this.socket.close(1000, "Node-Destroy"); this.socket.removeAllListeners(); this.socket = null; this.reconnectAttempts = 1; clearTimeout(this.reconnectTimeout); if (deleteNode) { this.NodeManager.emit("destroy", this, destroyReason); this.NodeManager.nodes.delete(this.id); clearInterval(this.heartBeatInterval); clearTimeout(this.pingTimeout); } else { this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason }); } }); } else { // If no players, proceed with socket cleanup immediately this.socket.close(1000, "Node-Destroy"); this.socket.removeAllListeners(); this.socket = null; this.reconnectAttempts = 1; clearTimeout(this.reconnectTimeout); if (deleteNode) { this.NodeManager.emit("destroy", this, destroyReason); this.NodeManager.nodes.delete(this.id); clearInterval(this.heartBeatInterval); clearTimeout(this.pingTimeout); } else { this.NodeManager.emit("disconnect", this, { code: 1000, reason: destroyReason }); } } return; } /** * Disconnects the Node-Connection (Websocket) * @param disconnectReason Disconnect Reason to use when disconnecting Node * @returns void * * Also the node will not get re-connected again. * * @example * ```ts * player.node.destroy("custom Player Destroy Reason", true); * ``` */ disconnect(disconnectReason) { if (!this.connected) return; this.socket.close(1000, "Node-Disconnect"); this.socket.removeAllListeners(); this.socket = null; this.reconnectAttempts = 1; clearTimeout(this.reconnectTimeout); this.NodeManager.emit("disconnect", this, { code: 1000, reason: disconnectReason }); } /** * Returns if connected to the Node. * * @example * ```ts * const isConnected = player.node.connected; * console.log("node is connected: ", isConnected ? "yes" : "no") * ``` */ get connected() { return this.socket && this.socket.readyState === WebSocket.OPEN; } /** * Returns the current ConnectionStatus * * @example * ```ts * try { * const statusOfConnection = player.node.connectionStatus; * console.log("node's connection status is:", statusOfConnection) * } catch (error) { * console.error("no socket available?", error) * } * ``` */ get connectionStatus() { if (!this.socket) throw new Error("no websocket was initialized yet"); return ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][this.socket.readyState] || "UNKNOWN"; } /** * Gets all Players of a Node * @returns array of players inside of lavalink * * @example * ```ts * const node = lavalink.nodes.get("NODEID"); * const playersOfLavalink = await node?.fetchAllPlayers(); * ``` */ async fetchAllPlayers() { if (!this.sessionId) throw new Error("The Lavalink-Node is either not ready, or not up to date!"); return this.request(`/sessions/${this.sessionId}/players`) || []; } /** * Gets specific Player Information * @returns lavalink player object if player exists on lavalink * * @example * ```ts * const node = lavalink.nodes.get("NODEID"); * const playerInformation = await node?.fetchPlayer("guildId"); * ``` */ async fetchPlayer(guildId) { if (!this.sessionId) throw new Error("The Lavalink-Node is either not ready, or not up to date!"); return this.request(`/sessions/${this.sessionId}/players/${guildId}`); } /** * Updates the session with and enables/disables resuming and timeout * @param resuming Whether resuming is enabled for this session or not * @param timeout The timeout in seconds (default is 60s) * @returns the result of the request * * @example * ```ts * const node = player.node || lavalink.nodes.get("NODEID"); * await node?.updateSession(true, 180e3); // will enable resuming for 180seconds * ``` */ async updateSession(resuming, timeout) { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); const data = {}; if (typeof resuming === "boolean") data.resuming = resuming; if (typeof timeout === "number" && timeout > 0) data.timeout = timeout; this.resuming = { enabled: typeof resuming === "boolean" ? resuming : false, timeout: typeof resuming === "boolean" && resuming === true ? timeout : null, }; return this.request(`/sessions/${this.sessionId}`, r => { r.method = "PATCH"; r.headers = { Authorization: this.options.authorization, 'Content-Type': 'application/json' }; r.body = JSON.stringify(data); }); } /** * Decode Track or Tracks */ decode = { /** * Decode a single track into its info * @param encoded valid encoded base64 string from a track * @param requester the requesteruser for building the track * @returns decoded track from lavalink * * @example * ```ts * const encodedBase64 = 'QAACDgMACk5vIERpZ2dpdHkAC0JsYWNrc3RyZWV0AAAAAAAEo4AABjkxNjQ5NgABAB9odHRwczovL2RlZXplci5jb20vdHJhY2svOTE2NDk2AQBpaHR0cHM6Ly9lLWNkbnMtaW1hZ2VzLmR6Y2RuLm5ldC9pbWFnZXMvY292ZXIvZGFlN2EyNjViNzlmYjcxMjc4Y2RlMjUwNDg0OWQ2ZjcvMTAwMHgxMDAwLTAwMDAwMC04MC0wLTAuanBnAQAMVVNJUjE5NjAwOTc4AAZkZWV6ZXIBAChObyBEaWdnaXR5OiBUaGUgVmVyeSBCZXN0IE9mIEJsYWNrc3RyZWV0AQAjaHR0cHM6Ly93d3cuZGVlemVyLmNvbS9hbGJ1bS8xMDMyNTQBACJodHRwczovL3d3dy5kZWV6ZXIuY29tL2FydGlzdC8xODYxAQBqaHR0cHM6Ly9lLWNkbnMtaW1hZ2VzLmR6Y2RuLm5ldC9pbWFnZXMvYXJ0aXN0L2YxNmNhYzM2ZmVjMzkxZjczN2I3ZDQ4MmY1YWM3M2UzLzEwMDB4MTAwMC0wMDAwMDAtODAtMC0wLmpwZwEAT2h0dHBzOi8vY2RuLXByZXZpZXctYS5kemNkbi5uZXQvc3RyZWFtL2MtYTE1Yjg1NzFhYTYyMDBjMDQ0YmY1OWM3NmVkOTEyN2MtNi5tcDMAAAAAAAAAAAA='; * const track = await player.node.decode.singleTrack(encodedBase64, interaction.user); * ``` */ singleTrack: async (encoded, requester) => { if (!encoded) throw new SyntaxError("No encoded (Base64 string) was provided"); // return the decoded + builded track return this.NodeManager.LavalinkManager.utils?.buildTrack(await this.request(`/decodetrack?encodedTrack=${encodeURIComponent(encoded.replace(/\s/g, ""))}`), requester); }, /** * Decodes multiple tracks into their info * @param encodeds valid encoded base64 string array from all tracks * @param requester the requesteruser for building the tracks * @returns array of all tracks you decoded * * @example * ```ts * const encodedBase64_1 = 'QAACDgMACk5vIERpZ2dpdHkAC0JsYWNrc3RyZWV0AAAAAAAEo4AABjkxNjQ5NgABAB9odHRwczovL2RlZXplci5jb20vdHJhY2svOTE2NDk2AQBpaHR0cHM6Ly9lLWNkbnMtaW1hZ2VzLmR6Y2RuLm5ldC9pbWFnZXMvY292ZXIvZGFlN2EyNjViNzlmYjcxMjc4Y2RlMjUwNDg0OWQ2ZjcvMTAwMHgxMDAwLTAwMDAwMC04MC0wLTAuanBnAQAMVVNJUjE5NjAwOTc4AAZkZWV6ZXIBAChObyBEaWdnaXR5OiBUaGUgVmVyeSBCZXN0IE9mIEJsYWNrc3RyZWV0AQAjaHR0cHM6Ly93d3cuZGVlemVyLmNvbS9hbGJ1bS8xMDMyNTQBACJodHRwczovL3d3dy5kZWV6ZXIuY29tL2FydGlzdC8xODYxAQBqaHR0cHM6Ly9lLWNkbnMtaW1hZ2VzLmR6Y2RuLm5ldC9pbWFnZXMvYXJ0aXN0L2YxNmNhYzM2ZmVjMzkxZjczN2I3ZDQ4MmY1YWM3M2UzLzEwMDB4MTAwMC0wMDAwMDAtODAtMC0wLmpwZwEAT2h0dHBzOi8vY2RuLXByZXZpZXctYS5kemNkbi5uZXQvc3RyZWFtL2MtYTE1Yjg1NzFhYTYyMDBjMDQ0YmY1OWM3NmVkOTEyN2MtNi5tcDMAAAAAAAAAAAA='; * const encodedBase64_2 = 'QAABJAMAClRhbGsgYSBMb3QACjQwNHZpbmNlbnQAAAAAAAHr1gBxTzpodHRwczovL2FwaS12Mi5zb3VuZGNsb3VkLmNvbS9tZWRpYS9zb3VuZGNsb3VkOnRyYWNrczo4NTE0MjEwNzYvMzUyYTRiOTAtNzYxOS00M2E5LWJiOGItMjIxMzE0YzFjNjNhL3N0cmVhbS9obHMAAQAsaHR0cHM6Ly9zb3VuZGNsb3VkLmNvbS80MDR2aW5jZW50L3RhbGstYS1sb3QBADpodHRwczovL2kxLnNuZGNkbi5jb20vYXJ0d29ya3MtRTN1ek5Gc0Y4QzBXLTAtb3JpZ2luYWwuanBnAQAMUVpITkExOTg1Nzg0AApzb3VuZGNsb3VkAAAAAAAAAAA='; * const tracks = await player.node.decode.multipleTracks([encodedBase64_1, encodedBase64_2], interaction.user); * ``` */ multipleTracks: async (encodeds, requester) => { if (!Array.isArray(encodeds) || !encodeds.every(v => typeof v === "string" && v.length > 1)) throw new SyntaxError("You need to provide encodeds, which is an array of base64 strings"); // return the decoded + builded tracks return await this.request(`/decodetracks`, r => { r.method = "POST"; r.body = JSON.stringify(encodeds); r.headers["Content-Type"] = "application/json"; }).then((r) => r.map(track => this.NodeManager.LavalinkManager.utils.buildTrack(track, requester))); } }; lyrics = { /** * Get the lyrics of a track * @param track the track to get the lyrics for * @param skipTrackSource wether to skip the track source or not * @returns the lyrics of the track * @example * * ```ts * const lyrics = await player.node.lyrics.get(track, true); * // use it of player instead: * // const lyrics = await player.getLyrics(track, true); * ``` */ get: async (track, skipTrackSource = false) => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); if (!this.info.plugins.find(v => v.name === "lavalyrics-plugin")) throw new RangeError(`there is no lavalyrics-plugin available in the lavalink node (required for lyrics): ${this.id}`); if (!this.info.plugins.find(v => v.name === "lavasrc-plugin") && !this.info.plugins.find(v => v.name === "java-lyrics-plugin")) throw new RangeError(`there is no lyrics source (via lavasrc-plugin / java-lyrics-plugin) available in the lavalink node (required for lyrics): ${this.id}`); const url = `/lyrics?track=${track.encoded}&skipTrackSource=${skipTrackSource}`; return (await this.request(url)); }, /** * Get the lyrics of the current playing track * * @param guildId the guild id of the player * @param skipTrackSource wether to skip the track source or not * @returns the lyrics of the current playing track * @example * ```ts * const lyrics = await player.node.lyrics.getCurrent(guildId); * // use it of player instead: * // const lyrics = await player.getCurrentLyrics(); * ``` */ getCurrent: async (guildId, skipTrackSource = false) => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); if (!this.info.plugins.find(v => v.name === "lavalyrics-plugin")) throw new RangeError(`there is no lavalyrics-plugin available in the lavalink node (required for lyrics): ${this.id}`); if (!this.info.plugins.find(v => v.name === "lavasrc-plugin") && !this.info.plugins.find(v => v.name === "java-lyrics-plugin")) throw new RangeError(`there is no lyrics source (via lavasrc-plugin / java-lyrics-plugin) available in the lavalink node (required for lyrics): ${this.id}`); const url = `/sessions/${this.sessionId}/players/${guildId}/track/lyrics?skipTrackSource=${skipTrackSource}`; return (await this.request(url)); }, /** * subscribe to lyrics updates for a guild * @param guildId the guild id of the player * @returns request data of the request * * @example * ```ts * await player.node.lyrics.subscribe(guildId); * // use it of player instead: * // const lyrics = await player.subscribeLyrics(); * ``` */ subscribe: async (guildId) => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); if (!this.info.plugins.find(v => v.name === "lavalyrics-plugin")) throw new RangeError(`there is no lavalyrics-plugin available in the lavalink node (required for lyrics): ${this.id}`); if (!this.info.plugins.find(v => v.name === "lavasrc-plugin") && !this.info.plugins.find(v => v.name === "java-lyrics-plugin")) throw new RangeError(`there is no lyrics source (via lavasrc-plugin / java-lyrics-plugin) available in the lavalink node (required for lyrics): ${this.id}`); return await this.request(`/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe`, (options) => { options.method = "POST"; }); }, /** * unsubscribe from lyrics updates for a guild * @param guildId the guild id of the player * @returns request data of the request * * @example * ```ts * await player.node.lyrics.unsubscribe(guildId); * // use it of player instead: * // const lyrics = await player.unsubscribeLyrics(); * ``` */ unsubscribe: async (guildId) => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); if (!this.info.plugins.find(v => v.name === "lavalyrics-plugin")) throw new RangeError(`there is no lavalyrics-plugin available in the lavalink node (required for lyrics): ${this.id}`); if (!this.info.plugins.find(v => v.name === "lavasrc-plugin") && !this.info.plugins.find(v => v.name === "java-lyrics-plugin")) throw new RangeError(`there is no lyrics source (via lavasrc-plugin / java-lyrics-plugin) available in the lavalink node (required for lyrics): ${this.id}`); return await this.request(`/sessions/${this.sessionId}/players/${guildId}/unsubscribe`); }, }; /** * Request Lavalink statistics. * @returns the lavalink node stats * * @example * ```ts * const lavalinkStats = await player.node.fetchStats(); * ``` */ async fetchStats() { return await this.request(`/stats`); } /** * Request Lavalink version. * @returns the current used lavalink version * * @example * ```ts * const lavalinkVersion = await player.node.fetchVersion(); * ``` */ async fetchVersion() { // need to adjust path for no-prefix version info return await this.request(`/version`, r => { r.path = "/version"; }, true); } /** * Request Lavalink information. * @returns lavalink info object * * @example * ```ts * const lavalinkInfo = await player.node.fetchInfo(); * const availablePlugins:string[] = lavalinkInfo.plugins.map(plugin => plugin.name); * const availableSources:string[] = lavalinkInfo.sourceManagers; * ``` */ async fetchInfo() { return await this.request(`/info`); } /** * Lavalink's Route Planner Api */ routePlannerApi = { /** * Get routplanner Info from Lavalink for ip rotation * @returns the status of the routeplanner * * @example * ```ts * const routePlannerStatus = await player.node.routePlannerApi.getStatus(); * const usedBlock = routePlannerStatus.details?.ipBlock; * const currentIp = routePlannerStatus.currentAddress; * ``` */ getStatus: async () => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); return await this.request(`/routeplanner/status`); }, /** * Release blacklisted IP address into pool of IPs for ip rotation * @param address IP address * @returns request data of the request * * @example * ```ts * await player.node.routePlannerApi.unmarkFailedAddress("ipv6address"); * ``` */ unmarkFailedAddress: async (address) => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); return await this.request(`/routeplanner/free/address`, r => { r.method = "POST"; r.headers["Content-Type"] = "application/json"; r.body = JSON.stringify({ address }); }); }, /** * Release all blacklisted IP addresses into pool of IPs * @returns request data of the request * * @example * ```ts * await player.node.routePlannerApi.unmarkAllFailedAddresses(); * ``` */ unmarkAllFailedAddresses: async () => { if (!this.sessionId) throw new Error("the Lavalink-Node is either not ready, or not up to date!"); return await this.request(`/routeplanner/free/all`, r => { r.method = "POST"; r.headers["Content-Type"] = "application/json"; }); } }; /** @private Utils for validating the */ validate() { if (!this.options.authorization) throw new SyntaxError("LavalinkNode requires 'authorization'"); if (!this.options.host) throw new SyntaxError("LavalinkNode requires 'host'"); if (!this.options.port) throw new SyntaxError("LavalinkNode requires 'port'"); // TODO add more validations } /** * Sync the data of the player you make an action to lavalink to * @param data data to use to update the player * @param res result data from lavalink, to override, if available * @returns boolean */ syncPlayerData(data, res) { if (typeof data === "object" && typeof data?.guildId === "string" && typeof data.playerOptions === "object" && Object.keys(data.playerOptions).length > 0) { const player = this.NodeManager.LavalinkManager.getPlayer(data.guildId); if (!player) return; if (typeof data.playerOptions.paused !== "undefined") { player.paused = data.playerOptions.paused; player.playing = !data.playerOptions.paused; } if (typeof data.playerOptions.position === "number") { // player.position = data.playerOptions.position; player.lastPosition = data.playerOptions.position; player.lastPositionChange = Date.now(); } if (typeof data.playerOptions.voice !== "undefined") player.voice = data.playerOptions.voice; if (typeof data.playerOptions.volume !== "undefined") { if (this.NodeManager.LavalinkManager.options.playerOptions.volumeDecrementer) { player.volume = Math.round(data.playerOptions.volume / this.NodeManager.LavalinkManager.options.playerOptions.volumeDecrementer); player.lavalinkVolume = Math.round(data.playerOptions.volume); } else { player.volume = Math.round(data.playerOptions.volume); player.lavalinkVolume = Math.round(data.playerOptions.volume); } } if (typeof data.playerOptions.filters !== "undefined") { const oldFilterTimescale = { ...player.filterManager.data.timescale }; Object.freeze(oldFilterTimescale); if (data.playerOptions.filters.timescale) player.filterManager.data.timescale = data.playerOptions.filters.timescale; if (data.playerOptions.filters.distortion) player.filterManager.data.distortion = data.playerOptions.filters.distortion; if (data.playerOptions.filters.pluginFilters) player.filterManager.data.pluginFilters = data.playerOptions.filters.pluginFilters; if (data.playerOptions.filters.vibrato) player.filterManager.data.vibrato = data.playerOptions.filters.vibrato; if (data.playerOptions.filters.volume) player.filterManager.data.volume = data.playerOptions.filters.volume; if (data.playerOptions.filters.equalizer) player.filterManager.equalizerBands = data.playerOptions.filters.equalizer; if (data.playerOptions.filters.karaoke) player.filterManager.data.karaoke = data.playerOptions.filters.karaoke; if (data.playerOptions.filters.lowPass) player.filterManager.data.lowPass = data.playerOptions.filters.lowPass; if (data.playerOptions.filters.rotation) player.filterManager.data.rotation = data.playerOptions.filters.rotation; if (data.playerOptions.filters.tremolo) player.filterManager.data.tremolo = data.playerOptions.filters.tremolo; player.filterManager.checkFiltersState(oldFilterTimescale); } } // just for res if (res?.guildId === "string" && typeof res?.voice !== "undefined") { const player = this.NodeManager.LavalinkManager.getPlayer(data.guildId); if (!player) return; if (typeof res?.voice?.connected === "boolean" && res.voice.connected === false) { player.destroy(DestroyReasons.LavalinkNoVoice); return; } player.ping.ws = res?.voice?.ping || player?.ping.ws; } return; } /** * Get the rest Adress for making requests */ get restAddress() { return `http${this.options.secure ? "s" : ""}://${this.options.host}:${this.options.port}`; } /** * Reconnect to the lavalink node * @param instaReconnect @default false wether to instantly try to reconnect * @returns void * * @example * ```ts * await player.node.reconnect(); * ``` */ reconnect(instaReconnect = false) { this.NodeManager.emit("reconnectinprogress", this); if (instaReconnect) { if (this.reconnectAttempts >= this.options.retryAmount) { const error = new Error(`Unable to connect after ${this.options.retryAmount} attempts.`); this.NodeManager.emit("error", this, error); return this.destroy(DestroyReasons.NodeReconnectFail); } this.socket.removeAllListeners(); this.socket = null; this.NodeManager.emit("reconnecting", this); this.connect(); this.reconnectAttempts++; return; } this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; if (this.reconnectAttempts >= this.options.retryAmount) { const error = new Error(`Unable to connect after ${this.options.retryAmount} attempts.`); this.NodeManager.emit("error", this, error); return this.destroy(DestroyReasons.NodeReconnectFail); } this.socket.removeAllListeners(); this.socket = null; this.NodeManager.emit("reconnecting", this); this.connect(); this.reconnectAttempts++; }, this.options.retryDelay || 1000); } /** @private util function for handling opening events from websocket */ async open() { this.isAlive = true; // trigger heartbeat-ping timeout - this is to check wether the client lost connection without knowing it if (this.options.enablePingOnStatsCheck) this.heartBeat(); if (this.heartBeatInterval) clearInterval(this.heartBeatInterval); if (this.options.heartBeatInterval > 0) { // everytime a pong happens, set this.isAlive to true this.socket.on("pong", () => { this.heartBeatPongTimestamp = performance.now(); this.isAlive = true; }); // every x ms send a ping to lavalink to retrieve a pong later on this.heartBeatInterval = setInterval(() => { if (!this.socket) return console.error("Node-Heartbeat-Interval - Socket not available - maybe reconnecting?"); if (!this.isAlive) this.close(500, "Node-Heartbeat-Timeout"); this.isAlive = false; this.heartBeatPingTimestamp = performance.now(); this.socket.ping(); }, this.options.heartBeatInterval || 30_000); } if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); // reset the reconnect attempts amount this.reconnectAttempts = 1; this.info = await this.fetchInfo().catch((e) => (console.error(e, "ON-OPEN-FETCH"), null)); if (!this.info && ["v3", "v4"].includes(this.version)) { const errorString = `Lavalink Node (${this.restAddress}) does not provide any /${this.version}/info`; throw new Error(errorString); } this.NodeManager.emit("connect", this); } /** @private util function for handling closing events from websocket */ close(code, reason) { if (this.pingTimeout) clearTimeout(this.pingTimeout); if (this.heartBeatInterval) clearInterval(this.heartBeatInterval); if (code === 1006 && !reason) reason = "Socket got terminated due to no ping connection"; if (code === 1000 && reason === "Node-Disconnect") return; // manually disconnected and already emitted the event. this.NodeManager.emit("disconnect", this, { code, reason }); if (code !== 1000 || reason !== "Node-Destroy") { if (this.NodeManager.nodes.has(this.id)) { // try to reconnect only when the node is still in the nodeManager.nodes list this.reconnect(); } } } /** @private util function for handling error events from websocket */ error(error) { if (!error)