UNPKG

magmastream

Version:

A user-friendly Lavalink client designed for NodeJS.

869 lines (868 loc) 40.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.JSONUtils = exports.Structure = exports.PlayerUtils = exports.AutoPlayUtils = exports.TrackUtils = void 0; const tslib_1 = require("tslib"); /* eslint-disable @typescript-eslint/no-require-imports */ const undici_1 = require("undici"); const jsdom_1 = require("jsdom"); const Enums_1 = require("./Enums"); const path_1 = tslib_1.__importDefault(require("path")); const safe_stable_stringify_1 = tslib_1.__importDefault(require("safe-stable-stringify")); const MagmastreamError_1 = require("./MagmastreamError"); // import { isPlainObject } from "lodash"; // import playwright from "playwright"; /** @hidden */ const SIZES = ["0", "1", "2", "3", "default", "mqdefault", "hqdefault", "maxresdefault"]; const REQUIRED_TRACK_KEYS = ["track", "title", "uri"]; class TrackUtils { static trackPartial = null; static manager; /** * Initializes the TrackUtils class with the given manager. * @param manager The manager instance to use. * @hidden */ static init(manager) { // Set the manager instance for TrackUtils. this.manager = manager; } /** * Sets the partial properties for the Track class. If a Track has some of its properties removed by the partial, * it will be considered a partial Track. * @param {TrackPartial} partial The array of string property names to remove from the Track class. */ static setTrackPartial(partial) { if (!Array.isArray(partial) || !partial.every((str) => typeof str === "string")) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_TRACK_PARTIAL_INVALID, message: "Partial must be an array of strings.", }); } const defaultProperties = [ Enums_1.TrackPartial.Track, Enums_1.TrackPartial.Title, Enums_1.TrackPartial.Author, Enums_1.TrackPartial.Duration, Enums_1.TrackPartial.Uri, Enums_1.TrackPartial.SourceName, Enums_1.TrackPartial.ArtworkUrl, Enums_1.TrackPartial.Requester, ]; /** The array of property names that will be removed from the Track class */ this.trackPartial = Array.from(new Set([...defaultProperties, ...partial])); /** Make sure that the "track" property is always included */ if (!this.trackPartial.includes(Enums_1.TrackPartial.Track)) this.trackPartial.unshift(Enums_1.TrackPartial.Track); } /** * Checks if the provided argument is a valid Track. * @param value The value to check. * @returns {boolean} Whether the provided argument is a valid Track. */ static isTrack(track) { if (typeof track !== "object" || track === null) return false; const t = track; return REQUIRED_TRACK_KEYS.every((key) => typeof t[key] === "string"); } /** * Checks if the provided argument is a valid Track array. * @param value The value to check. * @returns {boolean} Whether the provided argument is a valid Track array. */ static isTrackArray(value) { return Array.isArray(value) && value.every(this.isTrack); } /** * Checks if the provided argument is a valid Track or Track array. * @param value The value to check. * @returns {boolean} Whether the provided argument is a valid Track or Track array. */ static validate(value) { return this.isTrack(value) || this.isTrackArray(value); } /** * Builds a Track from the raw data from Lavalink and a optional requester. * @param data The raw data from Lavalink to build the Track from. * @param requester The user who requested the track, if any. * @param isAutoPlay Whether the track is autoplayed. Defaults to false. * @returns The built Track. */ static build(data, requester, isAutoplay = false) { if (typeof data === "undefined") { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_TRACK_BUILD_FAILED, message: 'Argument "data" must be present.', }); } try { const sourceNameMap = { applemusic: "AppleMusic", audius: "Audius", bandcamp: "Bandcamp", deezer: "Deezer", jiosaavn: "Jiosaavn", soundcloud: "SoundCloud", spotify: "Spotify", tidal: "Tidal", youtube: "YouTube", vkmusic: "VKMusic", qobuz: "Qobuz", http: "Http", tts: "Tts", clypit: "Clypit", pornhub: "Pornhub", soundgasm: "Soundgasm", reddit: "Reddit", flowertts: "Flowertts", ocremix: "Ocremix", mixcloud: "Mixcloud", tiktok: "TikTok", }; const track = { track: data.encoded, title: data.info.title, identifier: data.info.identifier, author: data.info.author, duration: data.info.length, isrc: data.info?.isrc, isSeekable: data.info.isSeekable, isStream: data.info.isStream, uri: data.info.uri, artworkUrl: data.info?.artworkUrl ?? null, sourceName: sourceNameMap[data.info?.sourceName?.toLowerCase() ?? ""] ?? data.info?.sourceName, thumbnail: data.info.uri.includes("youtube") ? `https://img.youtube.com/vi/${data.info.identifier}/default.jpg` : null, displayThumbnail(size = "default") { const finalSize = SIZES.find((s) => s === size) ?? "default"; return this.uri.includes("youtube") ? `https://img.youtube.com/vi/${data.info.identifier}/${finalSize}.jpg` : null; }, requester: requester, pluginInfo: data.pluginInfo, customData: {}, isAutoplay: isAutoplay, }; track.displayThumbnail = track.displayThumbnail.bind(track); if (this.trackPartial) { for (const key of Object.keys(track)) { if (this.trackPartial.includes(key)) continue; delete track[key]; } } return track; } catch (error) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_TRACK_BUILD_FAILED, message: `Argument "data" is not a valid track: ${error.message}`, context: { data, requester, }, }); } } /** * Validates a search result. * @param result The search result to validate. * @returns Whether the search result is valid. */ static isErrorOrEmptySearchResult(result) { return result.loadType === Enums_1.LoadTypes.Empty || result.loadType === Enums_1.LoadTypes.Error; } /** * Revives a track. * @param track The track to revive. * @returns The revived track. */ static revive(track) { if (!track) return track; track.displayThumbnail = function (size = "default") { const finalSize = SIZES.find((s) => s === size) ?? "default"; return this.uri.includes("youtube") ? `https://img.youtube.com/vi/${this.identifier}/${finalSize}.jpg` : null; }.bind(track); return track; } } exports.TrackUtils = TrackUtils; class AutoPlayUtils { static manager; // private static cachedAccessToken: string | null = null; // private static cachedAccessTokenExpiresAt: number = 0; /** * Initializes the AutoPlayUtils class with the given manager. * @param manager The manager instance to use. * @hidden */ static async init(manager) { if (!manager) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.GENERAL_INVALID_MANAGER, message: "AutoPlayUtils requires a valid Manager instance.", }); } this.manager = manager; } /** * Gets recommended tracks for the given track. * @param track The track to get recommended tracks for. * @returns An array of recommended tracks. */ static async getRecommendedTracks(track) { const node = this.manager.useableNode; if (!node) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.MANAGER_NO_NODES, message: "No available nodes to get recommended tracks from.", context: { track }, }); } const apiKey = this.manager.options.lastFmApiKey; // Check if Last.fm API is available if (apiKey) { return await this.getRecommendedTracksFromLastFm(track, apiKey); } const enabledSources = node.info.sourceManagers; const autoPlaySearchPlatforms = this.manager.options.autoPlaySearchPlatforms; // Iterate over autoplay platforms in order of priority for (const platform of autoPlaySearchPlatforms) { if (enabledSources.includes(platform)) { const recommendedTracks = await this.getRecommendedTracksFromSource(track, platform); // If tracks are found, return them immediately if (recommendedTracks.length > 0) { return recommendedTracks; } } } return []; } /** * Gets recommended tracks from Last.fm for the given track. * @param track The track to get recommended tracks for. * @param apiKey The API key for Last.fm. * @returns An array of recommended tracks. */ static async getRecommendedTracksFromLastFm(track, apiKey) { let { author: artist } = track; const { title } = track; 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 (0, undici_1.fetch)(noTitleUrl); const data = (await response.json()); if (data.error || !data.toptracks?.track?.length) { return []; } const randomTrack = data.toptracks.track[Math.floor(Math.random() * data.toptracks.track.length)]; const resolvedTracks = await this.resolveTracksFromQuery(`${randomTrack.artist.name} - ${randomTrack.name}`, this.manager.options.defaultSearchPlatform, track.requester); if (!resolvedTracks.length) return []; return resolvedTracks; } 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 (0, undici_1.fetch)(noArtistUrl); const noArtistData = (await response.json()); artist = noArtistData.results?.trackmatches?.track?.[0]?.artist; if (!artist) { return []; } } } // 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 similarData; try { const response = await (0, undici_1.fetch)(url); similarData = (await response.json()); } catch (error) { console.error("[AutoPlay] Error fetching similar tracks from Last.fm:", error); return []; } if (similarData.error || !similarData.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 (0, undici_1.fetch)(retryUrl); const retryData = (await retryResponse.json()); if (retryData.error || !retryData.toptracks?.track?.length) { return []; } const randomTrack = retryData.toptracks.track[Math.floor(Math.random() * retryData.toptracks.track.length)]; const resolvedTracks = await this.resolveTracksFromQuery(`${randomTrack.artist.name} - ${randomTrack.name}`, this.manager.options.defaultSearchPlatform, track.requester); if (!resolvedTracks.length) return []; const filteredTracks = resolvedTracks.filter((t) => t.uri !== track.uri); if (!filteredTracks.length) { return []; } return filteredTracks; } const randomTrack = similarData.similartracks.track.sort(() => Math.random() - 0.5).shift(); if (!randomTrack) { return []; } const resolvedTracks = await this.resolveTracksFromQuery(`${randomTrack.artist.name} - ${randomTrack.name}`, this.manager.options.defaultSearchPlatform, track.requester); if (!resolvedTracks.length) return []; return resolvedTracks; } /** * Gets recommended tracks from the given source. * @param track The track to get recommended tracks for. * @param platform The source to get recommended tracks from. * @returns An array of recommended tracks. */ static async getRecommendedTracksFromSource(track, platform) { const requester = track.requester; const parsedURL = new URL(track.uri); switch (platform) { case Enums_1.AutoPlayPlatform.Spotify: { const allowedSpotifyHosts = ["open.spotify.com", "www.spotify.com"]; if (!allowedSpotifyHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.Spotify, requester); if (!resolvedTrack) return []; track = resolvedTrack; } // const extractSpotifyArtistID = (url: string): string | null => { // const regex = /https:\/\/open\.spotify\.com\/artist\/([a-zA-Z0-9]+)/; // const match = url.match(regex); // return match ? match[1] : null; // }; // const identifier = `sprec:seed_artists=${extractSpotifyArtistID(track.pluginInfo.artistUrl)}&seed_tracks=${track.identifier}`; const identifier = `sprec:mix:track:${track.identifier}`; const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)); const tracks = this.buildTracksFromResponse(recommendedResult, requester); return tracks; } case Enums_1.AutoPlayPlatform.Deezer: { const allowedDeezerHosts = ["deezer.com", "www.deezer.com", "www.deezer.page.link"]; if (!allowedDeezerHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.Deezer, requester); if (!resolvedTrack) return []; track = resolvedTrack; } const identifier = `dzrec:${track.identifier}`; const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)); const tracks = this.buildTracksFromResponse(recommendedResult, requester); return tracks; } case Enums_1.AutoPlayPlatform.SoundCloud: { const allowedSoundCloudHosts = ["soundcloud.com", "www.soundcloud.com"]; if (!allowedSoundCloudHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.SoundCloud, requester); if (!resolvedTrack) return []; track = resolvedTrack; } let html; try { let recommendedRes = null; try { recommendedRes = await (0, undici_1.fetch)(`${track.uri}/recommended`); } catch (err) { console.error(`[AutoPlay] Failed to fetch SoundCloud recommendations.`, err); return []; } if (!recommendedRes.ok) { console.error(`[AutoPlay] Failed to fetch SoundCloud recommendations. Status: ${recommendedRes.status}`); return []; } html = await recommendedRes.text(); const dom = new jsdom_1.JSDOM(html); const window = dom.window; // Narrow the element types using instanceof const secondNoscript = window.querySelectorAll("noscript")[1]; if (!secondNoscript || !(secondNoscript instanceof window.Element)) return []; const sectionElement = secondNoscript.querySelector("section"); if (!sectionElement || !(sectionElement instanceof window.HTMLElement)) return []; const articleElements = sectionElement.querySelectorAll("article"); if (!articleElements || articleElements.length === 0) return []; const urls = Array.from(articleElements) .map((element) => { const h2 = element.querySelector('h2[itemprop="name"]'); if (!h2) return null; const a = h2.querySelector('a[itemprop="url"]'); if (!a) return null; const href = a.getAttribute("href"); return href ? `https://soundcloud.com${href}` : null; }) .filter(Boolean); if (!urls.length) return []; const randomUrl = urls[Math.floor(Math.random() * urls.length)]; const resolvedTrack = await this.resolveFirstTrackFromQuery(randomUrl, Enums_1.SearchPlatform.SoundCloud, requester); return resolvedTrack ? [resolvedTrack] : []; } catch (error) { console.error("[AutoPlay] Error occurred while fetching soundcloud recommendations:", error); return []; } } case Enums_1.AutoPlayPlatform.YouTube: { const allowedYouTubeHosts = ["youtube.com", "youtu.be"]; const hasYouTubeURL = allowedYouTubeHosts.some((url) => track.uri.includes(url)); let videoID = null; if (hasYouTubeURL) { videoID = track.uri.split("=").pop(); } else { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.YouTube, requester); if (!resolvedTrack) return []; videoID = resolvedTrack.uri.split("=").pop(); } if (!videoID) { return []; } let randomIndex; let searchURI; do { randomIndex = Math.floor(Math.random() * 23) + 2; searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`; } while (track.uri.includes(searchURI)); const resolvedTracks = await this.resolveTracksFromQuery(searchURI, Enums_1.SearchPlatform.YouTube, requester); const filteredTracks = resolvedTracks.filter((t) => t.uri !== track.uri); return filteredTracks; } case Enums_1.AutoPlayPlatform.Tidal: { const allowedTidalHosts = ["tidal.com", "www.tidal.com"]; if (!allowedTidalHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.Tidal, requester); if (!resolvedTrack) return []; track = resolvedTrack; } const identifier = `tdrec:${track.identifier}`; const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)); const tracks = this.buildTracksFromResponse(recommendedResult, requester); return tracks; } case Enums_1.AutoPlayPlatform.VKMusic: { const allowedVKHosts = ["vk.com", "www.vk.com", "vk.ru", "www.vk.ru"]; if (!allowedVKHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.VKMusic, requester); if (!resolvedTrack) return []; track = resolvedTrack; } const identifier = `vkrec:${track.identifier}`; const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)); const tracks = this.buildTracksFromResponse(recommendedResult, requester); return tracks; } case Enums_1.AutoPlayPlatform.Qobuz: { const allowedQobuzHosts = ["qobuz.com", "www.qobuz.com", "play.qobuz.com"]; if (!allowedQobuzHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.Qobuz, requester); if (!resolvedTrack) return []; track = resolvedTrack; } const identifier = `qbrec:${track.identifier}`; const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)); const tracks = this.buildTracksFromResponse(recommendedResult, requester); return tracks; } case Enums_1.AutoPlayPlatform.Yandex: { const allowedYandexHosts = ["music.yandex.ru", "yandex.ru", "www.yandex.ru"]; if (!allowedYandexHosts.includes(parsedURL.host)) { const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, Enums_1.SearchPlatform.Yandex, requester); if (!resolvedTrack) return []; track = resolvedTrack; } const identifier = `ymrec:${track.identifier}`; const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)); const tracks = this.buildTracksFromResponse(recommendedResult, requester); return tracks; } default: return []; } } /** * Searches for a track using the manager and returns resolved tracks. * @param query The search query (artist - title). * @param requester The requester who initiated the search. * @returns An array of resolved tracks, or an empty array if not found or error occurred. */ static async resolveTracksFromQuery(query, source, requester) { try { const searchResult = await this.manager.search({ query, source }, requester); if (TrackUtils.isErrorOrEmptySearchResult(searchResult)) { return []; } switch (searchResult.loadType) { 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: return searchResult.playlist.tracks; case Enums_1.LoadTypes.Track: case Enums_1.LoadTypes.Search: case Enums_1.LoadTypes.Short: return searchResult.tracks; default: return []; } } catch (error) { console.error("[TrackResolver] Failed to resolve query:", query, error); return []; } } /** * Resolves the first available track from a search query using the specified source. * Useful for normalizing tracks that lack platform-specific metadata or URIs. * * @param query - The search query string (usually "Artist - Title"). * @param source - The search platform to use (e.g., Spotify, Deezer, YouTube). * @param requester - The requester object, used for context or attribution. * @returns A single resolved {@link Track} object if found, or `null` if the search fails or returns no results. */ static async resolveFirstTrackFromQuery(query, source, requester) { try { const searchResult = await this.manager.search({ query, source }, requester); if (TrackUtils.isErrorOrEmptySearchResult(searchResult)) return null; switch (searchResult.loadType) { 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: return searchResult.playlist.tracks[0] || null; case Enums_1.LoadTypes.Track: case Enums_1.LoadTypes.Search: case Enums_1.LoadTypes.Short: return searchResult.tracks[0] || null; default: return null; } } catch (err) { console.error(`[AutoPlay] Failed to resolve track from query: "${query}" on source: ${source}`, err); return null; } } static isPlaylistRawData(data) { return typeof data === "object" && data !== null && Array.isArray(data.tracks); } static isTrackData(data) { return typeof data === "object" && data !== null && "encoded" in data && "info" in data; } static isTrackDataArray(data) { return (Array.isArray(data) && data.every((track) => typeof track === "object" && track !== null && "encoded" in track && "info" in track && typeof track.encoded === "string")); } static buildTracksFromResponse(recommendedResult, requester) { if (!recommendedResult) return []; if (TrackUtils.isErrorOrEmptySearchResult(recommendedResult)) return []; switch (recommendedResult.loadType) { case Enums_1.LoadTypes.Track: { const data = recommendedResult.data; if (!this.isTrackData(data)) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, message: "Invalid TrackData object.", context: { recommendedResult }, }); } return [TrackUtils.build(data, requester, true)]; } case Enums_1.LoadTypes.Short: case Enums_1.LoadTypes.Search: { const data = recommendedResult.data; if (!this.isTrackDataArray(data)) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, message: "Invalid TrackData[] array for LoadTypes.Search or Short.", context: { recommendedResult }, }); } return data.map((d) => TrackUtils.build(d, requester, true)); } 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 data = recommendedResult.data; if (this.isPlaylistRawData(data)) { return data.tracks.map((d) => TrackUtils.build(d, requester, true)); } throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, message: "Invalid playlist data for loadType: " + recommendedResult.loadType, context: { recommendedResult }, }); } default: throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, message: "Unsupported loadType: " + recommendedResult.loadType, context: { recommendedResult }, }); } } } exports.AutoPlayUtils = AutoPlayUtils; class PlayerUtils { static manager; /** * Initializes the PlayerUtils class with the given manager. * @param manager The manager instance to use. * @hidden */ static init(manager) { if (!manager) { throw new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.GENERAL_INVALID_MANAGER, message: "PlayerUtils requires a valid Manager instance.", }); } this.manager = manager; } /** * Serializes a Player instance to avoid circular references. * @param player The Player instance to serialize * @returns The serialized Player instance */ static async serializePlayer(player) { const isNonSerializable = (value) => { if (typeof value === "function" || typeof value === "symbol") return true; if (typeof value === "object" && value !== null) { const ctorName = value.constructor?.name ?? ""; return (value instanceof Map || value instanceof Set || value instanceof WeakMap || value instanceof WeakSet || ctorName === "Timeout" || ctorName === "Socket" || ctorName === "TLSSocket" || ctorName === "EventEmitter"); } return false; }; const safeSerialize = (obj) => { if (!obj || typeof obj !== "object") return obj; const result = {}; for (const [k, v] of Object.entries(obj)) { if (!isNonSerializable(v)) { result[k] = v; } } return result; }; try { const [current, tracks, previous] = await Promise.all([player.queue.getCurrent(), player.queue.getTracks(), player.queue.getPrevious()]); const serializeTrack = (track) => { if (!track || !track.identifier) return null; try { return { ...track, requester: track.requester ? { id: track.requester.id, username: track.requester.username } : null, displayThumbnail: undefined, }; } catch { return null; } }; const safeCurrent = current ? serializeTrack(current) : null; const safeTracks = tracks.map(serializeTrack).filter((t) => t !== null); const safePrevious = previous.map(serializeTrack).filter((t) => t !== null); let safeNode = null; if (player.node) { try { safeNode = JSON.parse(JSON.stringify(player.node, (key, value) => { if (["rest", "players", "shards", "manager"].includes(key)) return undefined; if (isNonSerializable(value)) return undefined; return value; })); } catch { safeNode = null; } } const serializableData = player.getSerializableData(); const nowPlayingMessage = serializableData.nowPlayingMessage; delete serializableData.nowPlayingMessage; const snapshot = { clusterId: player.clusterId, options: player.options, voiceState: player.voiceState, guildId: player.guildId, voiceChannelId: player.voiceChannelId ?? null, textChannelId: player.textChannelId ?? null, volume: player.volume, paused: player.paused, playing: player.playing, position: player.position, trackRepeat: player.trackRepeat, queueRepeat: player.queueRepeat, dynamicRepeat: player.dynamicRepeat, node: safeNode, queue: { current: safeCurrent, tracks: safeTracks, previous: safePrevious, }, filters: player.filters ? { distortion: player.filters.distortion ?? null, equalizer: player.filters.equalizer ?? [], karaoke: player.filters.karaoke ?? null, rotation: player.filters.rotation ?? null, timescale: player.filters.timescale ?? null, vibrato: player.filters.vibrato ?? null, reverb: player.filters.reverb ?? null, volume: player.filters.volume ?? 1.0, bassBoostlevel: player.filters.bassBoostlevel ?? null, filterStatus: { ...player.filters.filtersStatus }, } : null, data: { ...safeSerialize(serializableData), nowPlayingMessage: nowPlayingMessage ? { id: String(nowPlayingMessage.id), channelId: nowPlayingMessage.channelId, guildId: nowPlayingMessage.guildId, } : null, }, }; // Sanity check JSON.stringify(snapshot); return snapshot; } catch (err) { const error = err instanceof MagmastreamError_1.MagmaStreamError ? err : new MagmastreamError_1.MagmaStreamError({ code: Enums_1.MagmaStreamErrorCode.UTILS_PLAYER_SERIALIZE_FAILED, message: `An error occurred while serializing player: ${err instanceof Error ? err.message : String(err)}`, cause: err instanceof Error ? err : undefined, }); console.error(error); return null; } } /** * Gets the base directory for player data. */ static getPlayersBaseDir() { return path_1.default.join(process.cwd(), "magmastream", "sessionData", "players"); } /** * Gets the path to the player's directory. */ static getGuildDir(guildId) { return path_1.default.join(this.getPlayersBaseDir(), guildId); } /** * Gets the path to the player's state file. */ static getPlayerStatePath(guildId) { return path_1.default.join(this.getGuildDir(guildId), "state.json"); } /** * Gets the path to the player's current track file. */ static getPlayerCurrentPath(guildId) { return path_1.default.join(this.getGuildDir(guildId), "current.json"); } /** * Gets the path to the player's queue file. */ static getPlayerQueuePath(guildId) { return path_1.default.join(this.getGuildDir(guildId), "queue.json"); } /** * Gets the path to the player's previous tracks file. */ static getPlayerPreviousPath(guildId) { return path_1.default.join(this.getGuildDir(guildId), "previous.json"); } /** * Gets the Redis key for player storage. */ static getRedisKey() { let prefix = (this.manager.options.stateStorage.redisConfig.prefix ?? "magmastream:").trim(); prefix = prefix.replace(/:+$/g, "") + ":"; return prefix; } } exports.PlayerUtils = PlayerUtils; /** Gets or extends structures to extend the built in, or already extended, classes to add more functionality. */ class Structure { /** * Extends a class. * @param name * @param extender */ static extend(name, extender) { if (!structures[name]) throw new TypeError(`"${name} is not a valid structure`); const extended = extender(structures[name]); structures[name] = extended; return extended; } /** * Get a structure from available structures by name. * @param name */ static get(name) { const structure = structures[name]; if (!structure) throw new TypeError('"structure" must be provided.'); return structure; } } exports.Structure = Structure; class JSONUtils { static safe(obj, space) { return (0, safe_stable_stringify_1.default)(obj, null, space); } static serializeTrack(track) { const serialized = { ...track, requester: track.requester ? { id: track.requester.id, username: track.requester.username } : null, }; return JSON.stringify(serialized); } } exports.JSONUtils = JSONUtils; const structures = { Player: require("./Player").Player, Queue: require("../statestorage/MemoryQueue").MemoryQueue, Node: require("./Node").Node, Filters: require("./Filters").Filters, Manager: require("./Manager").Manager, Plugin: require("./Plugin").Plugin, Rest: require("./Rest").Rest, Utils: require("./Utils"), };