UNPKG

@persian-caesar/discord-player

Version:

Type definitions and helpers for a Discord music player (MusicPlayer) without external dependencies

523 lines 18.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MusicPlayer = void 0; const voice_1 = require("@discordjs/voice"); const types_1 = require("./types"); const html_to_text_1 = require("html-to-text"); const ytdl_core_discord_1 = __importDefault(require("ytdl-core-discord")); const events_1 = __importDefault(require("events")); const ytdl_core_1 = __importDefault(require("ytdl-core")); const play_dl_1 = __importDefault(require("play-dl")); const ytdl_core_2 = __importDefault(require("@distube/ytdl-core")); const soundcloud_downloader_1 = __importDefault(require("soundcloud-downloader")); /** * MusicPlayer * - Manages voice connection, playback, queue, history, and loop modes * - Emits events defined in MusicPlayerEvent */ class MusicPlayer extends events_1.default { channel; previousQueueOrder = []; connection = null; player; volume; queue = []; history = []; loopQueue = false; loopTrack = false; playing = false; autoLeaveOnEmptyQueue; autoLeaveOnIdleMs; idleTimer = null; shuffield = false; /** * @param channel The Discord voice channel to connect to * @param initialVolume Initial volume in percent (0–100) * @param options Configuration options (auto-leave, idle timeout) */ constructor(channel, initialVolume = 100, options = {}) { super(); this.channel = channel; // create the audio player and set volume this.player = (0, voice_1.createAudioPlayer)(); this.volume = Math.round(initialVolume / 100); this.autoLeaveOnEmptyQueue = options.autoLeaveOnEmptyQueue ?? true; this.autoLeaveOnIdleMs = options.autoLeaveOnIdleMs ?? 5 * 60_000; this.player.on("error", err => { this.emit(types_1.MusicPlayerEvent.Error, this.createError("Player have error => " + err.message)); }); this.player.on(voice_1.AudioPlayerStatus.Idle, () => this.onIdle()); this.player.on(voice_1.AudioPlayerStatus.Playing, () => this.clearIdleTimer()); } startIdleTimer() { if (this.autoLeaveOnIdleMs > 0 && !this.idleTimer && !this.playing) { this.idleTimer = setTimeout(() => { this.emit(types_1.MusicPlayerEvent.Disconnect); this.disconnect(); }, this.autoLeaveOnIdleMs); return; } return; } /** * Search Google and scrape lyrics snippets. * Returns the lyrics or null if not found. * * @param title Song title * @param artist Optional artist name, for more accurate search */ async searchLyrics(title, artist) { const delim1 = '</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">'; const delim2 = '</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">'; const GOOGLE = "https://www.google.com/search?q="; let html = ""; const query = encodeURIComponent(`${artist ? artist + " " : ""}${title}`); // build multiple query URLs with different suffixes const attempts = [ `${GOOGLE}${query}+lyrics`, `${GOOGLE}${query}+song+lyrics`, `${GOOGLE}${query}+song`, `${GOOGLE}${query}` ]; for (const url of attempts) { try { // fetch HTML, split by known delimiters, then strip tags const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } }); html = await res.text(); break; } catch { continue; } } if (!html) return null; let snippet; try { [, snippet] = html.split(delim1); [snippet] = snippet.split(delim2); } catch { return null; } const rawLines = snippet.split("\n"); let lyrics = ""; for (const line of rawLines) { lyrics += (0, html_to_text_1.htmlToText)(line).trim() + "\n"; } // lyrics = Buffer.from(lyrics, "binary").toString("utf8").trim(); lyrics = lyrics.trim(); return lyrics || null; } clearIdleTimer() { if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; return; } return; } /** * Connect to the voice channel if not already connected. * Waits until the connection is READY or emits an error. */ async ensureConnection() { // joinVoiceChannel will handle reconnection automatically if (!this.connection) { this.connection = (0, voice_1.joinVoiceChannel)({ channelId: this.channel.id, guildId: this.channel.guild.id, adapterCreator: this.channel.guild.voiceAdapterCreator }); try { await (0, voice_1.entersState)(this.connection, voice_1.VoiceConnectionStatus.Ready, 20_000); this.connection.subscribe(this.player); } catch (err) { // clean up on failure this.connection.destroy(); this.connection = null; this.emit(types_1.MusicPlayerEvent.Error, this.createError("Can't connect to the voice channel. => " + err.message)); } } } async search(query) { if (/^https?:\/\//.test(query)) return query; // soundcloud const sc_results = await soundcloud_downloader_1.default.search({ query, resourceType: "tracks", limit: 1 }); let url = sc_results.collection?.[0]?.permalink_url; if (!url) { // spotify youtube and also soundcloud const playdl_results = await play_dl_1.default.search(query, { limit: 1, source: { spotify: "track", soundcloud: "tracks", youtube: "video", deezer: "track" } }); url = playdl_results[0]?.url; } return url; } async createStreamFromScdl(url) { return await soundcloud_downloader_1.default.download(url); } async createStreamFromYtdl(url) { const options = { filter: "audioonly", highWaterMark: 1 << 25 }; let stream = null; try { stream = await (0, ytdl_core_2.default)(url, options); } catch { } ; if (!stream) try { stream = await (0, ytdl_core_1.default)(url, options); } catch { } ; if (!stream) try { stream = await (0, ytdl_core_discord_1.default)(url, options); } catch { } return stream; } async createStreamFromPlayDl(url) { const yt = await play_dl_1.default.stream(url, { quality: 2 }); if (!yt) return null; return yt.stream; } async fetchMetadata(url) { try { // YouTube if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//.test(url)) { let info; try { info = await ytdl_core_2.default.getBasicInfo(url); } catch { } ; if (!info) try { info = await ytdl_core_1.default.getBasicInfo(url); } catch { } ; if (!info) try { info = await ytdl_core_discord_1.default.getBasicInfo(url); } catch { } if (!info) return { title: undefined, author: undefined, duration: undefined, thumbnail: undefined, source: "unknown", url }; const details = info.videoDetails; return { title: details.title, author: details.author.name, duration: parseInt(details.lengthSeconds, 10), thumbnail: details.thumbnails[details.thumbnails.length - 1].url, source: "youtube", url }; } // SoundCloud if (/^https?:\/\/(soundcloud\.com|snd\.sc)\//.test(url)) { const info = await soundcloud_downloader_1.default.getInfo(url); return { title: info.title, author: info.user?.username, duration: Math.floor(info.duration / 1000), thumbnail: info.artwork_url || info.user?.avatar_url, source: "soundcloud", url }; } // Other sources can be added similarly with playdl const result = await play_dl_1.default.video_basic_info(url); const vid = result.video_details; return { title: vid.title, author: vid.channel?.name, duration: vid.durationInSec, thumbnail: vid.thumbnails?.[0]?.url, source: "unknown", url }; } catch { return { title: undefined, author: undefined, duration: undefined, thumbnail: undefined, source: "unknown", url }; } } async playUrl(url, metadata) { this.playing = true; this.history.push(url); // Create audio stream as before let stream = null; if (/^https?:\/\/(soundcloud\.com|snd\.sc)\//.test(url)) try { stream = await this.createStreamFromScdl(url); } catch { } ; if (/^https?:\/\/open\.spotify\.com\/(track|album|playlist)\//.test(url)) if (!stream) try { stream = await this.createStreamFromPlayDl(url); } catch { } ; if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//.test(url)) if (!stream) try { stream = await this.createStreamFromYtdl(url); } catch { } ; const resource = (0, voice_1.createAudioResource)(stream, { inlineVolume: true }); resource.volume?.setVolume(this.volume); this.player.play(resource); if (!this.player.state.resource) this.player.state.resource = resource; this.emit(types_1.MusicPlayerEvent.Start, { metadata, queue: [...this.queue] }); return; } /** * Play a song by query or URL. * If already playing, adds to queue and emits a queueAdd event. * Otherwise, starts immediate playback via playUrl(). * * @param input YouTube URL or search query (e.g. “Coldplay Yellow”) */ async play(input) { await this.ensureConnection(); // resolve to a URL, then fetch metadata const url = await this.search(input); const metadata = await this.fetchMetadata(url); if (this.playing) { // enqueue and notify this.queue.push(metadata); this.emit(types_1.MusicPlayerEvent.QueueAdd, { metadata, queue: [...this.queue] }); return; } else // start playback immediately return await this.playUrl(url, metadata); } pause() { this.player.pause(); this.emit(types_1.MusicPlayerEvent.Pause); } resume() { this.player.unpause(); this.emit(types_1.MusicPlayerEvent.Resume); } setVolume(percent) { percent /= 100; if (percent < 0 || percent > 2) this.volume = 2; else this.volume = percent; const resource = this.player.state.resource; try { resource.volume.setVolume(this.volume); } catch { } this.emit(types_1.MusicPlayerEvent.VolumeChange, { volume: Math.round(this.volume * 100) }); } /** * Handle when the player becomes idle. * - If loopTrack is on, replay current track * - Else if queue has items, play next * - Otherwise, emit finish and optionally disconnect */ async onIdle() { const url = this.history[this.history.length - 1]; const metadata = await this.fetchMetadata(url); if (this.loopTrack) return await this.playUrl(url, metadata); if (this.queue.length) { const next = this.queue.shift(); if (this.loopQueue) this.queue.push(next); const metadata = await this.fetchMetadata(next.url); return await this.playUrl(next.url, metadata); } this.playing = false; this.emit(types_1.MusicPlayerEvent.Finish, { queue: [...this.queue], history: [...this.history] }); if (this.autoLeaveOnEmptyQueue) { this.emit(types_1.MusicPlayerEvent.Disconnect); this.disconnect(); return; } else return this.startIdleTimer(); } skip() { this.emit(types_1.MusicPlayerEvent.Skip, { queue: [...this.queue], history: [...this.history] }); this.player.stop(); } /** * Jump to the previous track. * Pops current and last URLs from history, re-queues the current, * and starts playback of the previous one. */ async previous() { if (this.history.length < 2) { this.emit(types_1.MusicPlayerEvent.Error, this.createError("No track to previous.")); return; } // Remove current URL this.history.pop(); // Get the one before it const prevUrl = this.history.pop(); const metadata = await this.fetchMetadata(prevUrl); // Re-insert into the front of the queue this.queue.unshift(metadata); // Notify listeners with updated state this.emit(types_1.MusicPlayerEvent.Previous, { metadata, queue: [...this.queue], history: [...this.history, prevUrl] }); // Play the previous track await this.playUrl(prevUrl, metadata); return; } /** * Shuffle the queue randomly. * Saves the current queue order so you can undo. */ shuffle() { // Backup before shuffle this.previousQueueOrder = [...this.queue]; for (let i = this.queue.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [this.queue[i], this.queue[j]] = [this.queue[j], this.queue[i]]; } this.shuffield = true; this.emit(types_1.MusicPlayerEvent.Shuffle, { queue: [...this.queue] }); } /** * Restore the queue to its previous order, * excluding any tracks that have already been played. */ undoShuffle() { // Filter out URLs already in history this.queue = this.previousQueueOrder.filter(meta => !this.history.includes(meta.url)); this.shuffield = false; this.emit(types_1.MusicPlayerEvent.Shuffle, { queue: [...this.queue] }); } toggleLoopQueue() { this.loopQueue = !this.loopQueue; } isLoopQueue() { return this.loopQueue; } toggleLoopTrack() { this.loopTrack = !this.loopTrack; } isLoopTrack() { return this.loopTrack; } disconnect() { this.clearIdleTimer(); this.player?.stop(); if (this.connection) { this.connection.destroy(); this.connection = null; } this.playing = false; this.queue = []; this.history = []; this.emit(types_1.MusicPlayerEvent.Disconnect); } stop(noLeave = true) { this.emit(types_1.MusicPlayerEvent.Stop); this.player.stop(); this.playing = false; this.queue = []; this.history = []; this.clearIdleTimer(); if (!noLeave) this.disconnect(); return; } getQueue() { return [...this.queue]; } getVolume() { const resource = this.player.state.resource; if (resource && resource.volume && resource.volume.volume) return Math.round(resource.volume.volume * 100); return Math.round(this.volume * 100); } isPlaying() { return this.playing; } isPaused() { return this.player && this.player.state.status === voice_1.AudioPlayerStatus.Paused; } isShuffiled() { return this.shuffield; } // Custom error class createError(message) { class discordPlayerError extends Error { constructor() { super(); this.name = "Discord-Player"; this.message = message; } } return new discordPlayerError(); } } exports.MusicPlayer = MusicPlayer; /** * @copyright * Code by Sobhan-SRZA (mr.sinre) | https://github.com/Sobhan-SRZA * Developed for Persian Caesar | https://github.com/Persian-Caesar | https://dsc.gg/persian-caesar * * If you encounter any issues or need assistance with this code, * please make sure to credit "Persian Caesar" in your documentation or communications. */ //# sourceMappingURL=MusicPlayer.js.map