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
JavaScript
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)