magmastream
Version:
A user-friendly Lavalink client designed for NodeJS.
869 lines (868 loc) • 40.1 kB
JavaScript
;
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"),
};