UNPKG

magmastream

Version:

A user-friendly Lavalink client designed for NodeJS.

1,045 lines 56.2 kB
"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