magmastream
Version:
A user-friendly Lavalink client designed for NodeJS.
1,031 lines • 52.4 kB
JavaScript
"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