UNPKG

discord-portable-player

Version:

Easy to use, framework to facilitate music commands using discord.js

628 lines (627 loc) 30.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Player = void 0; const tslib_1 = require("tslib"); const discord_js_1 = require("discord.js"); const tiny_typed_emitter_1 = require("tiny-typed-emitter"); const Queue_1 = require("./Structures/Queue"); const VoiceUtils_1 = require("./VoiceInterface/VoiceUtils"); const types_1 = require("./types/types"); const Track_1 = tslib_1.__importDefault(require("./Structures/Track")); const QueryResolver_1 = require("./utils/QueryResolver"); const youtube_sr_1 = tslib_1.__importDefault(require("youtube-sr")); const Util_1 = require("./utils/Util"); const cross_fetch_1 = tslib_1.__importDefault(require("cross-fetch")); const PlayerError_1 = require("./Structures/PlayerError"); const ytdl_core_1 = require("ytdl-core"); const soundcloud_scraper_1 = require("soundcloud-scraper"); const Playlist_1 = require("./Structures/Playlist"); const ExtractorModel_1 = require("./Structures/ExtractorModel"); const voice_1 = require("@discordjs/voice"); const AppleMusic_1 = require("./utils/AppleMusic"); const types_2 = require("./types/types"); const Spotify = require("spotify-url-info")(cross_fetch_1.default); const soundcloud = new soundcloud_scraper_1.Client(); class Player extends tiny_typed_emitter_1.TypedEmitter { /** * Creates new Discord Portable Player * @param {Client} client The Discord Client * @param {PlayerInitOptions} [options={}] The player init options */ constructor(client, options = {}) { super(); this.options = { autoRegisterExtractor: true, ytdlOptions: { highWaterMark: 1 << 25 }, connectionTimeout: 20000 }; this.queues = new discord_js_1.Collection(); this.voiceUtils = new VoiceUtils_1.VoiceUtils(); this.extractors = new discord_js_1.Collection(); this.requiredEvents = ["error", "connectionError"]; /** * The discord.js client * @type {Client} */ this.client = client; if (this.client?.options?.intents && !new discord_js_1.IntentsBitField(this.client?.options?.intents).has(discord_js_1.IntentsBitField.Flags.GuildVoiceStates)) { throw new PlayerError_1.PlayerError('The client is missing "GuildVoiceStates" intent!'); } /** * The extractors collection * @type {ExtractorModel} */ this.options = Object.assign(this.options, options); this.client.on("voiceStateUpdate", this._handleVoiceState.bind(this)); if (this.options?.autoRegisterExtractor) { let nv; // eslint-disable-line @typescript-eslint/no-explicit-any if ((nv = Util_1.Util.require("@discord-player/extractor"))) { ["Attachment", "Facebook", "Reverbnation", "Vimeo"].forEach((ext) => void this.use(ext, nv[ext])); } } } /** * Handles voice state update * @param {VoiceState} oldState The old voice state * @param {VoiceState} newState The new voice state * @returns {void} * @private */ _handleVoiceState(oldState, newState) { const queue = this.getGuildQueue(oldState.guild.id); if (!queue || !queue.connection) return; if (oldState.channelId && !newState.channelId && newState.member.id === newState.guild.members.me.id) { try { queue.destroy(); } catch { Util_1.Util.noop; } return void this.emit(types_2.Events.BotDisconnect, queue); } if (!oldState.channelId && newState.channelId && newState.member.id === newState.guild.members.me.id) { if (!oldState.serverMute && newState.serverMute) { queue.setPaused(!!newState.serverMute); } else if (!oldState.suppress && newState.suppress) { queue.setPaused(!!newState.suppress); if (newState.suppress) { newState.guild.members.me.voice.setRequestToSpeak(true).catch(Util_1.Util.noop); } } } if (oldState.channelId === newState.channelId && newState.member.id === newState.guild.members.me.id) { if (!oldState.serverMute && newState.serverMute) { queue.setPaused(!!newState.serverMute); } else if (!oldState.suppress && newState.suppress) { queue.setPaused(!!newState.suppress); if (newState.suppress) { newState.guild.members.me.voice.setRequestToSpeak(true).catch(Util_1.Util.noop); } } } if (queue.connection && !newState.channelId && oldState.channelId === queue.connection.channel.id) { if (!Util_1.Util.isVoiceEmpty(queue.connection.channel)) return; const timeout = setTimeout(() => { if (!Util_1.Util.isVoiceEmpty(queue.connection.channel)) return; if (!this.queues.has(queue.guild.id)) return; if (queue.options.leaveOnEmpty) queue.destroy(true); this.emit(types_2.Events.ChannelEmpty, queue); }, queue.options.leaveOnEmptyCooldown || 0).unref(); queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout); } if (queue.connection && newState.channelId && newState.channelId === queue.connection.channel.id) { const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`); const channelEmpty = Util_1.Util.isVoiceEmpty(queue.connection.channel); if (!channelEmpty && emptyTimeout) { clearTimeout(emptyTimeout); queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`); } } if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId && newState.member.id === newState.guild.members.me.id) { if (queue.connection && newState.member.id === newState.guild.members.me.id) queue.connection.channel = newState.channel; const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`); const channelEmpty = Util_1.Util.isVoiceEmpty(queue.connection.channel); if (!channelEmpty && emptyTimeout) { clearTimeout(emptyTimeout); queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`); } else { const timeout = setTimeout(() => { if (queue.connection && !Util_1.Util.isVoiceEmpty(queue.connection.channel)) return; if (!this.queues.has(queue.guild.id)) return; if (queue.options.leaveOnEmpty) queue.destroy(true); this.emit(types_2.Events.ChannelEmpty, queue); }, queue.options.leaveOnEmptyCooldown || 0).unref(); queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout); } } } /** * Creates a queue for a guild if not available, else returns existing queue * @param {QueueOptions} queueInitOptions Queue init options / @param {PlayerOptions} playerInitOptions Player init options * @returns {Queue} */ createGuildQueue(playerInitOptions, queueInitOptions = {}) { playerInitOptions.guild = this.client.guilds.resolve(playerInitOptions.guild); if (!playerInitOptions.guild) throw new PlayerError_1.PlayerError("Unknown Guild", PlayerError_1.ErrorStatusCode.UNKNOWN_GUILD); if (this.queues.has(playerInitOptions.guild.id)) return this.queues.get(playerInitOptions.guild.id); const _meta = queueInitOptions.metadata; delete queueInitOptions["metadata"]; playerInitOptions.volumeSmoothness ?? (playerInitOptions.volumeSmoothness = 0.08); playerInitOptions.ytdlOptions ?? (playerInitOptions.ytdlOptions = this.options.ytdlOptions); const queue = new Queue_1.Queue(this, playerInitOptions.guild, queueInitOptions); queue.metadata = _meta; this.queues.set(playerInitOptions.guild.id, queue); return queue; } /** * Returns the queue if available * @param {GuildResolvable} guild The guild id * @returns {Queue} */ getGuildQueue(guild) { guild = this.client.guilds.resolve(guild); if (!guild) throw new PlayerError_1.PlayerError("Unknown Guild", PlayerError_1.ErrorStatusCode.UNKNOWN_GUILD); return this.queues.get(guild.id); } /** * Deletes a queue and returns deleted queue object * @param {GuildResolvable} guild The guild id to remove * @returns {Queue} */ deleteGuildQueue(guild) { guild = this.client.guilds.resolve(guild); if (!guild) throw new PlayerError_1.PlayerError("Unknown Guild", PlayerError_1.ErrorStatusCode.UNKNOWN_GUILD); const prev = this.getGuildQueue(guild); try { prev.destroy(); } catch { } // eslint-disable-line no-empty this.queues.delete(guild.id); return prev; } /** * @typedef {object} PlayerSearchResult * @property {Playlist} [playlist] The playlist (if any) * @property {Track[]} tracks The tracks */ /** * Search tracks * @param {string|Track} query The search query * @param {SearchOptions} options The search options * @returns {Promise<PlayerSearchResult>} */ async search(query, options) { if (query instanceof Track_1.default) return { playlist: query.playlist || null, tracks: [query] }; if (!options) throw new PlayerError_1.PlayerError("No search options were provided!", PlayerError_1.ErrorStatusCode.INVALID_ARG_TYPE); options.requestedBy = this.client.users.resolve(options.requestedBy); if (!("searchEngine" in options)) options.searchEngine = types_1.QueryType.Auto; if (typeof options.searchEngine === "string" && this.extractors.has(options.searchEngine)) { const extractor = this.extractors.get(options.searchEngine); if (!extractor.validate(query)) return { playlist: null, tracks: [] }; const data = await extractor.handle(query); if (data && data.data.length) { const playlist = !data.playlist ? null : new Playlist_1.Playlist(this, { ...data.playlist, tracks: [] }); const tracks = data.data.map((m) => new Track_1.default(this, { ...m, requestedBy: options.requestedBy, duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(m.duration)), playlist: playlist })); if (playlist) playlist.tracks = tracks; return { playlist: playlist, tracks: tracks }; } } // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [_, extractor] of this.extractors) { if (options.blockExtractor) break; if (!extractor.validate(query)) continue; const data = await extractor.handle(query); if (data && data.data.length) { const playlist = !data.playlist ? null : new Playlist_1.Playlist(this, { ...data.playlist, tracks: [] }); const tracks = data.data.map((m) => new Track_1.default(this, { ...m, requestedBy: options.requestedBy, duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(m.duration)), playlist: playlist })); if (playlist) playlist.tracks = tracks; return { playlist: playlist, tracks: tracks }; } } const qt = options.searchEngine === types_1.QueryType.Auto ? QueryResolver_1.QueryResolver.resolve(query) : options.searchEngine; switch (qt) { case types_1.QueryType.AppleMusicTrack: { const data = await (0, AppleMusic_1.search)(query); const videos = await youtube_sr_1.default.search(data.title, { type: "video" }); if (!videos) return null; const track = new Track_1.default(this, { title: data.title, description: videos[0].description, author: videos[0].channel.name, url: videos[0].url, requestedBy: options.requestedBy, thumbnail: videos[0].thumbnail.url, views: 0, duration: videos[0].durationFormatted, source: "applemusic", raw: videos[0] }); return { playlist: null, tracks: [track] }; } case types_1.QueryType.AppleMusicAlbum: case types_1.QueryType.AppleMusicPlaylist: { const data = (await (0, AppleMusic_1.search)(query)); const playlist = new Playlist_1.Playlist(this, { title: data.title, thumbnail: data.thumbnail, description: data.description, type: "playlist", source: "applemusic", author: { name: data.type === "playlist" ? data.creator.name : data.author.name, url: data.type === "playlist" ? data.creator.url : data.author.url }, tracks: [], id: "", url: query, rawPlaylist: data }); for (const m of data.tracks) { const videos = (await youtube_sr_1.default.search(m.title, { type: "video" }).catch(Util_1.Util.noop)); const data = new Track_1.default(this, { title: videos[0].title ?? "", description: videos[0].description ?? "", author: m.author.name ?? "Unknown Artist", url: videos[0].url, thumbnail: videos[0].thumbnail.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg", duration: videos[0].durationFormatted, views: 0, requestedBy: options.requestedBy, source: "applemusic" }); playlist.tracks.push(data); } return { playlist: playlist, tracks: playlist.tracks }; } case types_1.QueryType.YouTubeVideo: { const info = await (0, ytdl_core_1.getInfo)(query, this.options.ytdlOptions).catch(Util_1.Util.noop); if (!info) return { playlist: null, tracks: [] }; const track = new Track_1.default(this, { title: info.videoDetails.title, description: info.videoDetails.description, author: info.videoDetails.author?.name, url: info.videoDetails.video_url, requestedBy: options.requestedBy, thumbnail: Util_1.Util.last(info.videoDetails.thumbnails)?.url, views: parseInt(info.videoDetails.viewCount.replace(/[^0-9]/g, "")) || 0, duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(parseInt(info.videoDetails.lengthSeconds) * 1000)), source: "youtube", raw: info }); return { playlist: null, tracks: [track] }; } case types_1.QueryType.YouTubeSearch: { const videos = await youtube_sr_1.default.search(query, { type: "video" }).catch(Util_1.Util.noop); if (!videos) return { playlist: null, tracks: [] }; const tracks = videos.map((m) => { m.source = "youtube"; // eslint-disable-line @typescript-eslint/no-explicit-any return new Track_1.default(this, { title: m.title, description: m.description, author: m.channel?.name, url: m.url, requestedBy: options.requestedBy, thumbnail: m.thumbnail?.displayThumbnailURL("maxresdefault"), views: m.views, duration: m.durationFormatted, source: "youtube", raw: m }); }); return { playlist: null, tracks }; } case types_1.QueryType.SoundCloudTrack: case types_1.QueryType.SoundCloudSearch: { const result = QueryResolver_1.QueryResolver.resolve(query) === types_1.QueryType.SoundCloudTrack ? [{ url: query }] : await soundcloud.search(query, "track").catch(() => []); if (!result || !result.length) return { playlist: null, tracks: [] }; const res = []; for (const r of result) { const trackInfo = await soundcloud.getSongInfo(r.url).catch(Util_1.Util.noop); if (!trackInfo) continue; const track = new Track_1.default(this, { title: trackInfo.title, url: trackInfo.url, duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(trackInfo.duration)), description: trackInfo.description, thumbnail: trackInfo.thumbnail, views: trackInfo.playCount, author: trackInfo.author.name, requestedBy: options.requestedBy, source: "soundcloud", engine: trackInfo }); res.push(track); } return { playlist: null, tracks: res }; } case types_1.QueryType.SpotifySong: { const spotifyData = await Spotify.getData(query).catch(Util_1.Util.noop); if (!spotifyData) return { playlist: null, tracks: [] }; const spotifyTrack = new Track_1.default(this, { title: spotifyData.name, description: spotifyData.description ?? "", author: spotifyData.artists[0]?.name ?? "Unknown Artist", url: spotifyData.external_urls?.spotify ?? query, thumbnail: spotifyData.album?.images[0]?.url ?? spotifyData.preview_url?.length ? `https://i.scdn.co/image/${spotifyData.preview_url?.split("?cid=")[1]}` : "https://www.scdn.co/i/_global/twitter_card-default.jpg", duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(spotifyData.duration_ms)), views: 0, requestedBy: options.requestedBy, source: "spotify" }); return { playlist: null, tracks: [spotifyTrack] }; } case types_1.QueryType.SpotifyPlaylist: case types_1.QueryType.SpotifyAlbum: { const spotifyPlaylist = await Spotify.getData(query).catch(Util_1.Util.noop); if (!spotifyPlaylist) return { playlist: null, tracks: [] }; const playlist = new Playlist_1.Playlist(this, { title: spotifyPlaylist.name ?? spotifyPlaylist.title, description: spotifyPlaylist.description ?? "", thumbnail: spotifyPlaylist.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg", type: spotifyPlaylist.type, source: "spotify", author: spotifyPlaylist.type !== "playlist" ? { name: spotifyPlaylist.artists[0]?.name ?? "Unknown Artist", url: spotifyPlaylist.artists[0]?.external_urls?.spotify ?? null } : { name: spotifyPlaylist.owner?.display_name ?? spotifyPlaylist.owner?.id ?? "Unknown Artist", url: spotifyPlaylist.owner?.external_urls?.spotify ?? null }, tracks: [], id: spotifyPlaylist.id, url: spotifyPlaylist.external_urls?.spotify ?? query, rawPlaylist: spotifyPlaylist }); if (spotifyPlaylist.type !== "playlist") { // eslint-disable-next-line @typescript-eslint/no-explicit-any playlist.tracks = spotifyPlaylist.tracks.items.map((m) => { const data = new Track_1.default(this, { title: m.name ?? "", description: m.description ?? "", author: m.artists[0]?.name ?? "Unknown Artist", url: m.external_urls?.spotify ?? query, thumbnail: spotifyPlaylist.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg", duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(m.duration_ms)), views: 0, requestedBy: options.requestedBy, playlist, source: "spotify" }); return data; }); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any playlist.tracks = spotifyPlaylist.tracks.items.map((m) => { const data = new Track_1.default(this, { title: m.track.name ?? "", description: m.track.description ?? "", author: m.track.artists[0]?.name ?? "Unknown Artist", url: m.track.external_urls?.spotify ?? query, thumbnail: m.track.album?.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg", duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(m.track.duration_ms)), views: 0, requestedBy: options.requestedBy, playlist, source: "spotify" }); return data; }); } return { playlist: playlist, tracks: playlist.tracks }; } case types_1.QueryType.SoundCloudPlaylist: { const data = await soundcloud.getPlaylist(query).catch(Util_1.Util.noop); if (!data) return { playlist: null, tracks: [] }; const res = new Playlist_1.Playlist(this, { title: data.title, description: data.description ?? "", thumbnail: data.thumbnail ?? "https://soundcloud.com/pwa-icon-192.png", type: "playlist", source: "soundcloud", author: { name: data.author?.name ?? data.author?.username ?? "Unknown Artist", url: data.author?.profile }, tracks: [], id: `${data.id}`, url: data.url, rawPlaylist: data }); for (const song of data.tracks) { const track = new Track_1.default(this, { title: song.title, description: song.description ?? "", author: song.author?.username ?? song.author?.name ?? "Unknown Artist", url: song.url, thumbnail: song.thumbnail, duration: Util_1.Util.buildTimeCode(Util_1.Util.parseMS(song.duration)), views: song.playCount ?? 0, requestedBy: options.requestedBy, playlist: res, source: "soundcloud", engine: song }); res.tracks.push(track); } return { playlist: res, tracks: res.tracks }; } case types_1.QueryType.YouTubePlaylist: { const ytpl = await youtube_sr_1.default.getPlaylist(query).catch(Util_1.Util.noop); if (!ytpl) return { playlist: null, tracks: [] }; await ytpl.fetch().catch(Util_1.Util.noop); const playlist = new Playlist_1.Playlist(this, { title: ytpl.title, thumbnail: ytpl.thumbnail, description: "", type: "playlist", source: "youtube", author: { name: ytpl.channel.name, url: ytpl.channel.url }, tracks: [], id: ytpl.id, url: ytpl.url, rawPlaylist: ytpl }); playlist.tracks = ytpl.videos.map((video) => new Track_1.default(this, { title: video.title, description: video.description, author: video.channel?.name, url: video.url, requestedBy: options.requestedBy, thumbnail: video.thumbnail.url, views: video.views, duration: video.durationFormatted, raw: video, playlist: playlist, source: "youtube" })); return { playlist: playlist, tracks: playlist.tracks }; } default: return { playlist: null, tracks: [] }; } } /** * Registers extractor * @param {string} extractorName The extractor name * @param {ExtractorModel|any} extractor The extractor object * @param {boolean} [force=false] Overwrite existing extractor with this name (if available) * @returns {ExtractorModel} */ // eslint-disable-next-line @typescript-eslint/no-explicit-any use(extractorName, extractor, force = false) { if (!extractorName) throw new PlayerError_1.PlayerError("Cannot use unknown extractor!", PlayerError_1.ErrorStatusCode.UNKNOWN_EXTRACTOR); if (this.extractors.has(extractorName) && !force) return this.extractors.get(extractorName); if (extractor instanceof ExtractorModel_1.ExtractorModel) { this.extractors.set(extractorName, extractor); return extractor; } for (const method of ["validate", "getInfo"]) { if (typeof extractor[method] !== "function") throw new PlayerError_1.PlayerError("Invalid extractor data!", PlayerError_1.ErrorStatusCode.INVALID_EXTRACTOR); } const model = new ExtractorModel_1.ExtractorModel(extractorName, extractor); this.extractors.set(model.name, model); return model; } /** * Removes registered extractor * @param {string} extractorName The extractor name * @returns {ExtractorModel} */ unuse(extractorName) { if (!this.extractors.has(extractorName)) throw new PlayerError_1.PlayerError(`Cannot find extractor "${extractorName}"`, PlayerError_1.ErrorStatusCode.UNKNOWN_EXTRACTOR); const prev = this.extractors.get(extractorName); this.extractors.delete(extractorName); return prev; } /** * Generates a report of the dependencies used by the `@discordjs/voice` module. Useful for debugging. * @returns {string} */ scanDeps() { const line = "-".repeat(50); const depsReport = (0, voice_1.generateDependencyReport)(); const extractorReport = this.extractors .map((m) => { return `${m.name} :: ${m.version || "0.1.0"}`; }) .join("\n"); return `${depsReport}\n${line}\nLoaded Extractors:\n${extractorReport || "None"}`; } emit(eventName, ...args) { if (this.requiredEvents.includes(eventName) && !super.eventNames().includes(eventName)) { // eslint-disable-next-line no-console console.error(...args); process.emitWarning(`Warning: Unhandled "${eventName}" event! Events ${this.requiredEvents.map((m) => `"${m}"`).join(", ")} must have event listeners!`); return false; } else { return super.emit(eventName, ...args); } } /** * Resolves queue * @param {GuildResolvable|Queue} queueLike Queue like object * @returns {Queue} */ resolveQueue(queueLike) { return this.getGuildQueue(queueLike instanceof Queue_1.Queue ? queueLike.guild : queueLike); } *[Symbol.iterator]() { yield* Array.from(this.queues.values()); } } exports.Player = Player;