magmastream
Version:
A user-friendly Lavalink client designed for NodeJS.
1,045 lines • 56.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Manager = void 0;
const tslib_1 = require("tslib");
const Utils_1 = require("./Utils");
const collection_1 = require("@discordjs/collection");
const events_1 = require("events");
const Node_1 = require("./Node");
const __1 = require("..");
const managerCheck_1 = tslib_1.__importDefault(require("../utils/managerCheck"));
const blockedWords_1 = require("../config/blockedWords");
const promises_1 = tslib_1.__importDefault(require("fs/promises"));
const path_1 = tslib_1.__importDefault(require("path"));
const ioredis_1 = tslib_1.__importDefault(require("ioredis"));
const Enums_1 = require("./Enums");
const package_json_1 = require("../../package.json");
const MagmastreamError_1 = require("./MagmastreamError");
const lodash_1 = tslib_1.__importDefault(require("lodash"));
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const YOUTUBE_URL_PATTERN = /(youtube\.com|youtu\.be)/;
const BLOCKED_WORDS_PATTERN = new RegExp(`\\b(${blockedWords_1.blockedWords.map(escapeRegExp).join("|")})\\b`, "gi");
/**
* The main hub for interacting with Lavalink and using Magmastream.
*/
class Manager extends events_1.EventEmitter {
/** The map of players. */
players = new collection_1.Collection();
/** The map of nodes. */
nodes = new collection_1.Collection();
/** The options that were set. */
options;
initiated = false;
redis;
_send;
_getUser;
_getGuild;
loadedPlugins = new Set();
constructor(options, isWrapper = false) {
super();
(0, managerCheck_1.default)(options, isWrapper);
// Initialize structures
Utils_1.Structure.get("Player").init(this);
Utils_1.TrackUtils.init(this);
Utils_1.PlayerUtils.init(this);
if (options.trackPartial) {
Utils_1.TrackUtils.setTrackPartial(options.trackPartial);
delete options.trackPartial;
}
if (options.clientId)
this.options.clientId = options.clientId;
if (options.clusterId)
this.options.clusterId = options.clusterId;
if (options.send && !this._send)
this._send = options.send;
if (options.getUser && !this._getUser)
this._getUser = options.getUser;
if (options.getGuild && !this._getGuild)
this._getGuild = options.getGuild;
this.options = {
...options,
enabledPlugins: options.enabledPlugins ?? [],
nodes: options.nodes ?? [
{
identifier: "Cheap lavalink hosting @",
host: "https://blackforthosting.com/products?category=lavalink",
port: 443,
password: "Try BlackForHosting",
useSSL: true,
enableSessionResumeOption: false,
sessionTimeoutSeconds: 1000,
nodePriority: 69,
},
],
playNextOnEnd: options.playNextOnEnd ?? true,
enablePriorityMode: options.enablePriorityMode ?? false,
clientName: options.clientName ?? `Magmastream/${package_json_1.version}`,
defaultSearchPlatform: options.defaultSearchPlatform ?? Enums_1.SearchPlatform.YouTube,
useNode: options.useNode ?? Enums_1.UseNodeOptions.LeastPlayers,
maxPreviousTracks: options.maxPreviousTracks ?? 20,
normalizeYouTubeTitles: options.normalizeYouTubeTitles ?? false,
stateStorage: {
...options.stateStorage,
type: options.stateStorage?.type ?? Enums_1.StateStorageType.Memory,
deleteDestroyedPlayers: options.stateStorage?.deleteDestroyedPlayers ?? true,
},
autoPlaySearchPlatforms: options.autoPlaySearchPlatforms ?? [Enums_1.AutoPlayPlatform.YouTube],
listenToSIGEvents: options.listenToSIGEvents ?? true,
send: this._send,
};
Utils_1.AutoPlayUtils.init(this);
if (this.options.nodes) {
for (const nodeOptions of this.options.nodes)
new Node_1.Node(this, nodeOptions);
}
if (this.options.listenToSIGEvents) {
process.on("SIGINT", async () => {
console.warn("\x1b[33mSIGINT received! Graceful shutdown initiated...\x1b[0m");
try {
await this.handleShutdown();
console.warn("\x1b[32mShutdown complete. Waiting for Node.js event loop to empty...\x1b[0m");
// Prevent forced exit by Windows
setTimeout(() => {
process.exit(0);
}, 2000);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_SHUTDOWN_FAILED,
message: "An unknown error occurred.",
cause: err,
context: { stage: "SIGINT" },
});
console.error(error);
process.exit(1);
}
});
process.on("SIGTERM", async () => {
console.warn("\x1b[33mSIGTERM received! Graceful shutdown initiated...\x1b[0m");
try {
await this.handleShutdown();
console.warn("\x1b[32mShutdown complete. Exiting now...\x1b[0m");
process.exit(0);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_SHUTDOWN_FAILED,
message: "An unknown error occurred.",
cause: err,
context: { stage: "SIGTERM" },
});
console.error(error);
process.exit(1);
}
});
}
}
/**
* Initiates the Manager.
* @param clientId - The Discord client ID (only required when not using any of the magmastream wrappers).
* @param clusterId - The cluster ID which runs the current process (required).
* @returns The manager instance.
*/
async init(options = {}) {
if (this.initiated) {
return this;
}
const { clientId, clusterId = 0 } = options;
if (clientId !== undefined) {
if (typeof clientId !== "string" || !/^\d+$/.test(clientId)) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_INIT_FAILED,
message: '"clientId" must be a valid Discord client ID.',
context: { clientId },
});
}
this.options.clientId = clientId;
}
if (typeof clusterId !== "number") {
console.warn(`[MANAGER] "clusterId" is not a valid number, defaulting to 0.`);
this.options.clusterId = 0;
}
else {
this.options.clusterId = clusterId;
}
if (this.options.stateStorage.type === Enums_1.StateStorageType.Redis) {
this.redis = new ioredis_1.default(lodash_1.default.omit(this.options.stateStorage.redisConfig, "prefix"));
}
const results = await Promise.allSettled([...this.nodes.values()].map(async (node) => {
await node.connect();
return node;
}));
for (let i = 0; i < results.length; i++) {
const result = results[i];
const node = [...this.nodes.values()][i];
if (result.status === "rejected") {
const err = result.reason;
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_CONNECT_FAILED,
message: `Failed to connect node "${node.options.identifier}".`,
cause: err instanceof Error ? err : undefined,
context: { nodeId: node.options.identifier },
});
this.emit(Enums_1.ManagerEventTypes.NodeError, node, error);
}
}
this.loadPlugins();
this.initiated = true;
return this;
}
/**
* Searches the enabled sources based off the URL or the `source` property.
* @param query
* @param requester
* @returns The search result.
*/
async search(query, requester) {
const node = this.useableNode;
if (!node) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_NO_NODES,
message: "No available nodes to perform the search.",
context: { query, requester },
});
}
const _query = typeof query === "string" ? { query } : query;
const _source = _query.source ?? this.options.defaultSearchPlatform;
const isUrl = /^https?:\/\//.test(_query.query);
const search = isUrl ? _query.query : `${_source}:${_query.query}`;
this.emit(Enums_1.ManagerEventTypes.Debug, isUrl ? `[MANAGER] Performing search for: ${_query.query}` : `[MANAGER] Performing ${_source} search for: ${_query.query}`);
try {
const res = (await node.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(search)}`));
if (!res) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.REST_REQUEST_FAILED,
message: `No results returned from Lavalink for query "${search}".`,
context: { query: search, requester },
});
}
let result;
switch (res.loadType) {
case Enums_1.LoadTypes.Search: {
const tracks = res.data.map((t) => Utils_1.TrackUtils.build(t, requester));
result = { loadType: res.loadType, tracks };
break;
}
case Enums_1.LoadTypes.Short:
case Enums_1.LoadTypes.Track: {
const track = Utils_1.TrackUtils.build(res.data, requester);
result = { loadType: res.loadType, tracks: [track] };
break;
}
case Enums_1.LoadTypes.Album:
case Enums_1.LoadTypes.Artist:
case Enums_1.LoadTypes.Station:
case Enums_1.LoadTypes.Podcast:
case Enums_1.LoadTypes.Show:
case Enums_1.LoadTypes.Playlist: {
const playlistData = res.data;
const tracks = playlistData.tracks.map((t) => Utils_1.TrackUtils.build(t, requester));
result = {
loadType: res.loadType,
tracks,
playlist: {
name: playlistData.info.name,
playlistInfo: playlistData.pluginInfo,
requester: requester,
tracks,
duration: tracks.reduce((acc, cur) => acc + (cur.duration || 0), 0),
},
};
break;
}
default:
result = { loadType: res.loadType };
}
if (this.options.normalizeYouTubeTitles && "tracks" in result) {
const processTrack = (track) => {
if (!YOUTUBE_URL_PATTERN.test(track.uri))
return track;
const { cleanTitle, cleanAuthor } = this.parseYouTubeTitle(track.title, track.author);
track.title = cleanTitle;
track.author = cleanAuthor;
return track;
};
result.tracks = result.tracks.map(processTrack);
if ("playlist" in result && result.playlist) {
result.playlist.tracks = result.tracks;
}
}
const summary = "tracks" in result ? result.tracks.map((t) => Object.fromEntries(Object.entries(t).filter(([key]) => key !== "requester"))) : [];
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Result search for ${_query.query}: ${Utils_1.JSONUtils.safe(summary, 2)}`);
return result;
}
catch (err) {
throw err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_SEARCH_FAILED,
message: `An error occurred while searching: ${err instanceof Error ? err.message : String(err)}`,
cause: err instanceof Error ? err : undefined,
context: { query, requester },
});
}
}
/**
* Returns a player or undefined if it does not exist.
* @param guildId The guild ID of the player to retrieve.
* @returns The player if it exists, undefined otherwise.
*/
getPlayer(guildId) {
return this.players.get(guildId);
}
/**
* Creates a player or returns one if it already exists.
* @param options The options to create the player with.
* @returns The created player.
*/
create(options) {
if (this.players.has(options.guildId)) {
return this.players.get(options.guildId);
}
// Create a new player with the given options
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Creating new player with options: ${Utils_1.JSONUtils.safe(options, 2)}`);
return new (Utils_1.Structure.get("Player"))(options);
}
/**
* Destroys a player.
* @param guildId The guild ID of the player to destroy.
* @returns A promise that resolves when the player has been destroyed.
*/
async destroy(guildId) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Destroying player: ${guildId}`);
const player = this.getPlayer(guildId);
if (!player)
return;
await player.destroy();
}
/**
* Creates a new node or returns an existing one if it already exists.
* @param options - The options to create the node with.
* @returns The created node.
*/
createNode(options) {
const key = options.identifier || options.host;
// Check if the node already exists in the manager's collection
if (this.nodes.has(key)) {
// Return the existing node if it does
return this.nodes.get(key);
}
const node = new Node_1.Node(this, options);
// Set the node in the manager's collection
this.nodes.set(key, node);
// Emit a debug event for node creation
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Creating new node with options: ${Utils_1.JSONUtils.safe(options, 2)}`);
// Return the created node
return node;
}
/**
* Destroys a node if it exists. Emits a debug event if the node is found and destroyed.
* @param identifier - The identifier of the node to destroy.
* @returns {void}
* @emits {debug} - Emits a debug message indicating the node is being destroyed.
*/
async destroyNode(identifier) {
const node = this.nodes.get(identifier);
if (!node) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Tried to destroy non-existent node: ${identifier}`);
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_NODE_NOT_FOUND,
message: "Node not found.",
context: { identifier },
});
}
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Destroying node: ${identifier}`);
this.nodes.delete(identifier);
await node.destroy();
}
/**
* Attaches an event listener to the manager.
* @param event The event to listen for.
* @param listener The function to call when the event is emitted.
* @returns The manager instance for chaining.
*/
on(event, listener) {
return super.on(event, listener);
}
/**
* Updates the voice state of a player based on the provided data.
* @param data - The data containing voice state information, which can be a VoicePacket, VoiceServer, or VoiceState.
* @returns A promise that resolves when the voice state update is handled.
* @emits {debug} - Emits a debug message indicating the voice state is being updated.
*/
async updateVoiceState(data) {
if (!this.isVoiceUpdate(data))
return;
const update = "d" in data ? data.d : data;
if (!this.isValidUpdate(update))
return;
const player = this.getPlayer(update.guild_id);
if (!player)
return;
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Updating voice state: ${Utils_1.JSONUtils.safe(update, 2)}`);
if ("token" in update) {
return await this.handleVoiceServerUpdate(player, update);
}
if (update.user_id !== this.options.clientId)
return;
return await this.handleVoiceStateUpdate(player, update);
}
/**
* Decodes an array of base64 encoded tracks and returns an array of TrackData.
* Emits a debug event with the tracks being decoded.
* @param tracks - An array of base64 encoded track strings.
* @returns A promise that resolves to an array of TrackData objects.
* @throws Will throw an error if no nodes are available or if the API request fails.
*/
async decodeTracks(tracks) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Decoding tracks: ${Utils_1.JSONUtils.safe(tracks, 2)}`);
return new Promise(async (resolve, reject) => {
const node = this.useableNode;
if (!node) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_NO_NODES,
message: "No available nodes to decode tracks.",
});
}
const res = (await node.rest.post("/v4/decodetracks", Utils_1.JSONUtils.safe(tracks, 2)).catch((err) => reject(err)));
if (!res) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.REST_REQUEST_FAILED,
message: "No decoded tracks returned from node.",
});
}
return resolve(res);
});
}
/**
* Decodes a base64 encoded track and returns a TrackData.
* @param track - The base64 encoded track string.
* @returns A promise that resolves to a TrackData object.
* @throws Will throw an error if no nodes are available or if the API request fails.
*/
async decodeTrack(track) {
const res = await this.decodeTracks([track]);
// Since we're only decoding one track, we can just return the first element of the array
return res[0];
}
/**
* Saves player states.
* @param {string} guildId - The guild ID of the player to save
*/
async savePlayerState(guildId) {
const player = this.getPlayer(guildId);
if (!player || player.state === Enums_1.StateTypes.Disconnected || !player.voiceChannelId) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Skipping save for inactive player: ${guildId}`);
return;
}
const serializedPlayer = await Utils_1.PlayerUtils.serializePlayer(player);
switch (this.options.stateStorage.type) {
case Enums_1.StateStorageType.Memory:
case Enums_1.StateStorageType.JSON:
{
try {
const playerStateFilePath = Utils_1.PlayerUtils.getPlayerStatePath(guildId);
await promises_1.default.mkdir(path_1.default.dirname(playerStateFilePath), { recursive: true });
await promises_1.default.writeFile(playerStateFilePath, Utils_1.JSONUtils.safe(serializedPlayer, 2), "utf-8");
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Player state saved: ${guildId}`);
}
catch (error) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Error saving player state for guild ${guildId}: ${error}`);
}
}
break;
case Enums_1.StateStorageType.Redis:
{
try {
const redisKey = `${Utils_1.PlayerUtils.getRedisKey()}playerstore:${guildId}`;
await this.redis.set(redisKey, JSON.stringify(serializedPlayer));
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Player state saved to Redis: ${guildId}`);
}
catch (error) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Error saving player state to Redis for guild ${guildId}: ${error}`);
}
}
break;
default:
return;
}
await player.queue.clear();
await player.queue.clearPrevious();
await player.queue.setCurrent(null);
}
/**
* Sleeps for a specified amount of time.
* @param ms The amount of time to sleep in milliseconds.
* @returns A promise that resolves after the specified amount of time.
*/
async sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async restorePlayerFromState(node, nodeId, guildId, state, cleanup) {
if (!state.guildId || state.node?.options?.identifier !== nodeId)
return;
const hasGuild = this.resolveGuild(state.guildId);
if (!hasGuild)
return;
const lavaPlayer = (await node.rest.get(`/v4/sessions/${state.node.sessionId}/players/${state.guildId}`));
if (!lavaPlayer)
return;
const playerOptions = {
guildId: state.options.guildId,
textChannelId: state.options.textChannelId,
voiceChannelId: state.options.voiceChannelId,
selfDeafen: state.options.selfDeafen,
volume: lavaPlayer.volume || state.options.volume,
nodeIdentifier: nodeId,
applyVolumeAsFilter: state.options.applyVolumeAsFilter,
pauseOnDisconnect: state.options.pauseOnDisconnect,
};
const player = this.create(playerOptions);
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Recreating player: ${guildId}`);
if (state.isAutoplay) {
const savedUser = state.data?.clientUser;
if (savedUser) {
const autoPlayUser = await player.manager.resolveUser(savedUser);
player.setAutoplay(true, autoPlayUser, state.autoplayTries);
}
}
const savedNowPlayingMessage = state.data?.nowPlayingMessage;
if (savedNowPlayingMessage) {
player.setNowPlayingMessage(savedNowPlayingMessage);
}
await this.restoreQueue(node, player, state, lavaPlayer);
await this.restorePreviousQueue(player, state);
this.restoreRepeatState(player, state);
this.restorePlayerData(player, state);
this.restoreFilters(player, state);
player.connect();
if (lavaPlayer.track && state.clusterId !== player.clusterId) {
const currentTrack = state.queue.current;
await player.play(Utils_1.TrackUtils.build(lavaPlayer.track, currentTrack.requester, currentTrack.isAutoplay), { startTime: lavaPlayer.state.position ?? 0 });
await node.rest.delete(`/v4/sessions/${state.node.sessionId}/players/${state.guildId}`);
}
if (state.paused)
await player.pause(true);
await cleanup();
this.emit(Enums_1.ManagerEventTypes.PlayerRestored, player, node);
await this.sleep(1000);
}
async restoreQueue(node, player, state, lavaPlayer) {
const currentTrack = state.queue.current;
const queueTracks = state.queue.tracks;
if (lavaPlayer.track) {
await player.queue.clear();
if (currentTrack) {
await player.queue.add(Utils_1.TrackUtils.build(lavaPlayer.track, currentTrack.requester, currentTrack.isAutoplay));
}
const remainingQueue = queueTracks.filter((t) => t.uri !== lavaPlayer.track.info.uri);
if (remainingQueue.length > 0)
await player.queue.add(remainingQueue);
player.playing = !lavaPlayer.paused;
return;
}
// No active lavalink track
if (currentTrack) {
if (queueTracks.length > 0) {
await player.queue.clear();
await player.queue.add(queueTracks);
}
await node.trackEnd(player, currentTrack, {
reason: Enums_1.TrackEndReasonTypes.Finished,
type: "TrackEndEvent",
});
return;
}
// No current track either — check previous
const previousQueue = await player.queue.getPrevious();
const lastTrack = previousQueue?.at(-1);
if (queueTracks.length > 0) {
await player.queue.clear();
await player.queue.add(queueTracks);
}
if (lastTrack || queueTracks.length > 0) {
await node.trackEnd(player, lastTrack, {
reason: Enums_1.TrackEndReasonTypes.Finished,
type: "TrackEndEvent",
});
}
}
async restorePreviousQueue(player, state) {
if (state.queue.previous.length > 0) {
const validPrevious = state.queue.previous.filter((t) => t !== null && typeof t.identifier === "string");
if (validPrevious.length > 0)
await player.queue.addPrevious(validPrevious);
}
else {
await player.queue.clearPrevious();
}
}
restoreRepeatState(player, state) {
if (state.trackRepeat)
player.setTrackRepeat(true);
if (state.queueRepeat)
player.setQueueRepeat(true);
if (state.dynamicRepeat && state.dynamicLoopInterval) {
player.setDynamicRepeat(state.dynamicRepeat, state.dynamicLoopInterval);
}
}
restorePlayerData(player, state) {
if (!state.data)
return;
for (const [name, value] of Object.entries(state.data)) {
player.set(name, value);
}
}
restoreFilters(player, state) {
const filterActions = {
bassboost: () => player.filters.bassBoost(state.filters.bassBoostlevel),
distort: (e) => player.filters.distort(e),
setDistortion: () => player.filters.setDistortion(state.filters.distortion),
eightD: (e) => player.filters.eightD(e),
setKaraoke: () => player.filters.setKaraoke(state.filters.karaoke),
nightcore: (e) => player.filters.nightcore(e),
slowmo: (e) => player.filters.slowmo(e),
soft: (e) => player.filters.soft(e),
trebleBass: (e) => player.filters.trebleBass(e),
setTimescale: () => player.filters.setTimescale(state.filters.timescale),
tv: (e) => player.filters.tv(e),
vibrato: () => player.filters.setVibrato(state.filters.vibrato),
vaporwave: (e) => player.filters.vaporwave(e),
pop: (e) => player.filters.pop(e),
party: (e) => player.filters.party(e),
earrape: (e) => player.filters.earrape(e),
electronic: (e) => player.filters.electronic(e),
radio: (e) => player.filters.radio(e),
setRotation: () => player.filters.setRotation(state.filters.rotation),
tremolo: (e) => player.filters.tremolo(e),
china: (e) => player.filters.china(e),
chipmunk: (e) => player.filters.chipmunk(e),
darthvader: (e) => player.filters.darthvader(e),
daycore: (e) => player.filters.daycore(e),
doubletime: (e) => player.filters.doubletime(e),
demon: (e) => player.filters.demon(e),
};
for (const [filter, isEnabled] of Object.entries(state.filters.filterStatus)) {
if (isEnabled && filterActions[filter])
filterActions[filter](true);
}
}
/**
* Loads player states from the JSON file.
* @param nodeId The ID of the node to load player states from.
* @returns A promise that resolves when the player states have been loaded.
*/
async loadPlayerStates(nodeId) {
this.emit(Enums_1.ManagerEventTypes.Debug, "[MANAGER] Loading saved players.");
const node = this.nodes.get(nodeId);
if (!node) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_NODE_NOT_FOUND,
message: "Node not found.",
context: { nodeId },
});
}
switch (this.options.stateStorage.type) {
case Enums_1.StateStorageType.Memory:
case Enums_1.StateStorageType.JSON: {
const playersBaseDir = Utils_1.PlayerUtils.getPlayersBaseDir();
try {
await promises_1.default.access(playersBaseDir).catch(async () => {
await promises_1.default.mkdir(playersBaseDir, { recursive: true });
});
const guildDirs = await promises_1.default.readdir(playersBaseDir, { withFileTypes: true });
for (const file of guildDirs) {
if (!file.isDirectory())
continue;
const guildId = file.name;
const stateFilePath = Utils_1.PlayerUtils.getPlayerStatePath(guildId);
try {
await promises_1.default.access(stateFilePath);
const state = JSON.parse(await promises_1.default.readFile(stateFilePath, "utf-8"));
await this.restorePlayerFromState(node, nodeId, guildId, state, async () => {
await promises_1.default.rm(stateFilePath, { force: true });
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Deleted state for guild ${guildId}`);
});
}
catch (error) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Error processing guild ${guildId}: ${error}`);
}
}
}
catch (error) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Error loading player states: ${error}`);
}
break;
}
case Enums_1.StateStorageType.Redis: {
try {
const keys = await this.redis.keys(`${Utils_1.PlayerUtils.getRedisKey()}playerstore:*`);
for (const key of keys) {
try {
const data = await this.redis.get(key);
if (!data)
continue;
const state = JSON.parse(data);
if (!state || typeof state !== "object")
continue;
const guildId = key.split(":").pop();
if (!guildId)
continue;
await this.restorePlayerFromState(node, nodeId, guildId, state, async () => {
await this.redis.del(key);
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Deleted Redis state: ${key}`);
});
}
catch (error) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Error processing Redis key ${key}: ${error}`);
}
}
}
catch (error) {
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Error loading player states from Redis: ${error}`);
}
break;
}
}
this.emit(Enums_1.ManagerEventTypes.Debug, "[MANAGER] Finished loading saved players.");
this.emit(Enums_1.ManagerEventTypes.RestoreComplete, node);
}
/**
* Returns the node to use based on the configured `useNode` and `enablePriorityMode` options.
* If `enablePriorityMode` is true, the node is chosen based on priority, otherwise it is chosen based on the `useNode` option.
* If `useNode` is "leastLoad", the node with the lowest load is chosen, if it is "leastPlayers", the node with the fewest players is chosen.
* If `enablePriorityMode` is false and `useNode` is not set, the node with the lowest load is chosen.
* @returns {Node} The node to use.
*/
get useableNode() {
return this.options.enablePriorityMode ? this.priorityNode : this.options.useNode === Enums_1.UseNodeOptions.LeastLoad ? this.leastLoadNode.first() : this.leastPlayersNode.first();
}
/**
* Handles the shutdown of the process by saving all active players' states.
* This function is called when the process is about to exit.
* It iterates through all players and calls {@link savePlayerState} to save their states.
* After saving, it exits the process.
* @param stopProcess - A function to stop the process.
*/
async handleShutdown(stopProcess) {
this.unloadPlugins();
console.warn("\x1b[31m%s\x1b[0m", "MAGMASTREAM WARNING: Shutting down! Please wait, saving active players...");
try {
const savePromises = Array.from(this.players.keys()).map(async (guildId) => {
try {
await this.savePlayerState(guildId);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_SHUTDOWN_FAILED,
message: "Error saving player state.",
cause: err,
context: { guildId },
});
console.error(error);
}
});
await Promise.allSettled(savePromises);
setTimeout(async () => {
console.warn("\x1b[32m%s\x1b[0m", "MAGMASTREAM INFO: Shutting down complete, exiting...");
if (stopProcess)
await stopProcess();
else
process.exit(0);
}, 500);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_SHUTDOWN_FAILED,
message: "Error saving player state.",
cause: err,
context: { stage: "SHUTDOWN" },
});
console.error(error);
process.exit(1);
}
}
/**
* Parses a YouTube title into a clean title and author.
* @param title - The original title of the YouTube video.
* @param originalAuthor - The original author of the YouTube video.
* @returns An object with the clean title and author.
*/
parseYouTubeTitle(title, originalAuthor) {
// Remove "- Topic" from author and "Topic -" from title
const cleanAuthor = originalAuthor.replace("- Topic", "").trim();
title = title.replace("Topic -", "").trim();
// Remove blocked words and phrases
title = title.replace(BLOCKED_WORDS_PATTERN, "").trim();
// Remove empty brackets and balance remaining brackets
title = title
.replace(/[([{]\s*[)\]}]/g, "") // Empty brackets
.replace(/^[^\w\d]*|[^\w\d]*$/g, "") // Leading/trailing non-word characters
.replace(/\s{2,}/g, " ") // Multiple spaces
.trim();
// Remove '@' symbol before usernames
title = title.replace(/@(\w+)/g, "$1");
// Balance remaining brackets
title = this.balanceBrackets(title);
// Check if the title contains a hyphen, indicating potential "Artist - Title" format
if (title.includes(" - ")) {
const [artist, songTitle] = title.split(" - ").map((part) => part.trim());
// If the artist part matches or is included in the clean author, use the clean author
if (artist.toLowerCase() === cleanAuthor.toLowerCase() || cleanAuthor.toLowerCase().includes(artist.toLowerCase())) {
return { cleanAuthor, cleanTitle: songTitle };
}
// If the artist is different, keep both parts
return { cleanAuthor: artist, cleanTitle: songTitle };
}
// If no clear artist-title separation, return clean author and cleaned title
return { cleanAuthor, cleanTitle: title };
}
/**
* Balances brackets in a given string by ensuring all opened brackets are closed correctly.
* @param str - The input string that may contain unbalanced brackets.
* @returns A new string with balanced brackets.
*/
balanceBrackets(str) {
const stack = [];
const openBrackets = "([{";
const closeBrackets = ")]}";
let result = "";
// Iterate over each character in the string
for (const char of str) {
// If the character is an open bracket, push it onto the stack and add to result
if (openBrackets.includes(char)) {
stack.push(char);
result += char;
}
// If the character is a close bracket, check if it balances with the last open bracket
else if (closeBrackets.includes(char)) {
if (stack.length > 0 && openBrackets.indexOf(stack[stack.length - 1]) === closeBrackets.indexOf(char)) {
stack.pop();
result += char;
}
}
// If it's neither, just add the character to the result
else {
result += char;
}
}
// Close any remaining open brackets by adding the corresponding close brackets
while (stack.length > 0) {
const lastOpen = stack.pop();
result += closeBrackets[openBrackets.indexOf(lastOpen)];
}
return result;
}
/**
* Escapes a string by replacing special regex characters with their escaped counterparts.
* @param string - The string to escape.
* @returns The escaped string.
*/
/**
* Checks if the given data is a voice update.
* @param data The data to check.
* @returns Whether the data is a voice update.
*/
isVoiceUpdate(data) {
return "t" in data && ["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(data.t);
}
/**
* Determines if the provided update is a valid voice update.
* A valid update must contain either a token or a session_id.
*
* @param update - The voice update data to validate, which can be a VoicePacket, VoiceServer, or VoiceState.
* @returns {boolean} - True if the update is valid, otherwise false.
*/
isValidUpdate(update) {
return update && ("token" in update || "session_id" in update);
}
/**
* Handles a voice server update by updating the player's voice state and sending the voice state to the Lavalink node.
* @param player The player for which the voice state is being updated.
* @param update The voice server data received from Discord.
* @returns A promise that resolves when the voice state update is handled.
* @emits {debug} - Emits a debug message indicating the voice state is being updated.
*/
async handleVoiceServerUpdate(player, update) {
player.voiceState.event = update;
const sessionId = player.voiceState.sessionId;
const channelId = player.voiceState.channelId;
this.emit(Enums_1.ManagerEventTypes.Debug, `Updated voice server for player ${player.guildId} with token ${update.token} | endpoint ${update.endpoint} | sessionId ${sessionId} | channelId ${channelId}`);
await player.updateVoice();
}
/**
* Handles a voice state update by updating the player's voice channel and session ID if provided, or by disconnecting and destroying the player if the channel ID is null.
* @param player The player for which the voice state is being updated.
* @param update The voice state data received from Discord.
* @emits {playerMove} - Emits a player move event if the channel ID is provided and the player is currently connected to a different voice channel.
* @emits {playerDisconnect} - Emits a player disconnect event if the channel ID is null.
*/
async handleVoiceStateUpdate(player, update) {
this.emit(Enums_1.ManagerEventTypes.Debug, `Updated voice state for player ${player.guildId} with channel id ${update.channel_id} and session id ${update.session_id}`);
if (!update.channel_id) {
this.emit(Enums_1.ManagerEventTypes.PlayerDisconnect, player, player.voiceChannelId);
player.voiceChannelId = null;
player.state = Enums_1.StateTypes.Disconnected;
player.voiceState = Object.assign({});
if (player.options.pauseOnDisconnect)
await player.pause(true);
return;
}
if (player.voiceChannelId !== update.channel_id) {
this.emit(Enums_1.ManagerEventTypes.PlayerMove, player, player.voiceChannelId, update.channel_id);
}
player.voiceState.sessionId = update.session_id;
player.voiceState.channelId = update.channel_id;
player.voiceChannelId = update.channel_id;
player.options.voiceChannelId = update.channel_id;
await player.updateVoice();
}
/**
* Cleans up an inactive player by removing its state data.
* This is done to prevent stale state data from accumulating.
* @param guildId The guild ID of the player to clean up.
*/
async cleanupInactivePlayer(guildId) {
const player = this.getPlayer(guildId);
switch (this.options.stateStorage.type) {
case Enums_1.StateStorageType.JSON:
{
try {
if (!player) {
const guildDir = Utils_1.PlayerUtils.getGuildDir(guildId);
await promises_1.default.rm(guildDir, { recursive: true, force: true });
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Deleted inactive player data folder: ${guildId}`);
}
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_CLEANUP_INACTIVE_PLAYERS_FAILED,
message: "Error cleaning up inactive player.",
cause: err,
context: { guildId },
});
console.error(error);
}
}
break;
case Enums_1.StateStorageType.Redis:
{
try {
if (!player) {
const prefix = Utils_1.PlayerUtils.getRedisKey();
const keysToDelete = [
`${prefix}playerstore:${guildId}`,
`${prefix}queue:${guildId}:tracks`,
`${prefix}queue:${guildId}:current`,
`${prefix}queue:${guildId}:previous`,
];
await this.redis.del(...keysToDelete);
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Deleted Redis player and queue data for: ${guildId}`);
}
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_CLEANUP_INACTIVE_PLAYERS_FAILED,
message: "Error cleaning up inactive player.",
cause: err,
context: { guildId },
});
console.error(error);
}
}
break;
default:
break;
}
}
/**
* Loads the enabled plugins.
*/
loadPlugins() {
if (!Array.isArray(this.options.enabledPlugins))
return;
for (const [index, plugin] of this.options.enabledPlugins.entries()) {
// Validate plugin class
if (!(plugin instanceof __1.Plugin)) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLUGIN_LOAD_FAILED,
message: `Plugin at index ${index} does not extend Plugin.`,
context: { index, plugin },
});
}
try {
plugin.load(this);
this.loadedPlugins.add(plugin);
this.emit(Enums_1.ManagerEventTypes.Debug, `[PLUGIN] Loaded plugin: ${plugin.name}`);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLUGIN_RUNTIME_ERROR,
message: `Failed to load plugin "${plugin.name}".`,
cause: err instanceof Error ? err : undefined,
context: { pluginName: plugin.name, index },
});
this.emit(Enums_1.ManagerEventTypes.Debug, `[PLUGIN] ${error.name}: ${error.message}`);
}
}
}
/**
* Unloads the enabled plugins.
*/
unloadPlugins() {
for (const plugin of this.loadedPlugins) {
try {
plugin.unload(this);
this.emit(Enums_1.ManagerEventTypes.Debug, `[PLUGIN] Unloaded plugin: ${plugin.name}`);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLUGIN_RUNTIME_ERROR,
message: `Failed to unload plugin "${plugin.name}".`,
cause: err instanceof Error ? err : undefined,
context: { pluginName: plugin.name },
});
this.emit(Enums_1.ManagerEventTypes.Debug, `[PLUGIN] ${error.name}: ${error.message}`);
}
}
this.loadedPlugins.clear();
}
/**
* Clears all player states from the file system.
* This is done to prevent stale state files from accumulating on the file system.
*/
async clearAllStoredPlayers() {
switch (this.options.stateStorage.type) {
case Enums_1.StateStorageType.Memory:
case Enums_1.StateStorageType.JSON: {
const playersBaseDir = Utils_1.PlayerUtils.getPlayersBaseDir();
try {
await promises_1.default.access(playersBaseDir).catch(async () => {
await promises_1.default.mkdir(playersBaseDir, { recursive: true });
this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Created directory: ${playersBaseDir}`);
});
const files = await promises_1.default.readdir(playersBaseDir);
await Promise.all(files.map((file) => promises_1.default.unlink(path_1.default.join(playersBaseDir, file)).catch((err) => this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Failed to dele