diff --git a/Player.js b/Player.js index feb521b..27e53ad 100644 --- a/Player.js +++ b/Player.js @@ -1,623 +1,627 @@ -const EventEmitter = require("events"); -const { Revoice, MediaPlayer } = require("revoice.js"); -const { Worker } = require('worker_threads'); -const { PassThrough } = require("stream"); -const { spawn } = require("child_process"); -const meta = require("./src/probe.js"); -const fs = require('fs'); - -class RevoltPlayer extends EventEmitter { - constructor(token, opts) { - super(); - - this.voice = opts.voice || new Revoice(token, undefined, opts.client); - this.connection = { state: Revoice.State.OFFLINE }; - - this.upload = opts.uploader || new Uploader(opts.client, true); - - this.spotify = opts.spotifyClient || new Spotify(opts.spotify); - this.spotifyConfig = opts.spotify; - - this.ytdlp = opts.ytdlp; - this.innertube = opts.innertube; - - this.gClient = opts.geniusClient || new (require("genius-lyrics")).Client(); - this.port = 3050 + (opts.portOffset || 0); - this.updateHandler = (content, msg) => { msg.edit({ content: content }); } - this.messageChannel = opts.messageChannel; - this.LEAVE_TIMEOUT = opts.lTimeout || 45; - this.YT_API_KEY = opts.ytKey; - this.token = token; - this.REVOLT_CHAR_LIMIT = 1950; - this.resultLimit = 5; - this.startedPlaying = null; - this.searches = new Map(); - this.data = { - queue: [], - current: null, - loop: false, - loopSong: false - }; - - return this; - } - setUpdateHandler(handler) { - this.updateHandler = handler; - } - workerJob(jobId, data, onMessage = null, msg = null) { - return new Promise((res, rej) => { - const worker = new Worker('./worker.js', { workerData: { jobId, data } }); - worker.on("message", (data) => { - data = JSON.parse(data); - if (data.event == "error") { - rej(data.data); - } else if (data.event == "message" && (msg || onMessage)) { - if (msg) this.updateHandler(data.data, msg); - if (onMessage) onMessage(data.data); - } else if (data.event == "finished") { - res(data.data); - } - }); - worker.on("exit", (code) => { if (code == 0) rej(code) }); - }); - } - guid() { - var S4 = function () { - return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); - }; - return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); - } - - shuffleArr(a) { - var j, x, i; - for (i = a.length - 1; i > 0; i--) { - j = Math.floor(Math.random() * (i + 1)); - x = a[i]; - a[i] = a[j]; - a[j] = x; - } - return a; - } - addIdToQueue(id) { - return new Promise((res, _rej) => { - this.workerJob("search", id).then((data) => { - this.emit("queue", { - type: "add", - data: { - append: true, - data - } - }); - this.data.queue.push(data); - res(true); - }).catch(res(false)); - }); - } - addToQueue(data, top = false) { - this.emit("queue", { - type: "add", - data: { - append: !top, - data - } - }); - if (!top) return this.data.queue.push(data); - return this.data.queue.unshift(data); - } - prettifyMS(milliseconds) { - if (!milliseconds || isNaN(milliseconds) || milliseconds < 0) return "0:00"; - return new Date(milliseconds).toISOString().slice( - // if 1 hour passed, show the hour component, - // if 1 hour hasn't passed, don't show the hour component - milliseconds > 3600000 ? 11 : 14, - 19 - ); - } - - // music controls - shuffle() { - if (this.data.queue.length == 0) return "There is nothing to shuffle in the queue."; - this.data.queue = this.shuffleArr(this.data.queue); - this.emit("queue", { - type: "shuffle", - data: this.data.queue - }); - return; - } - get paused() { - if (!this.player) return false; - return this.player.playbackPaused || false; - } - pause() { - if (!this.player || !this.data.current) return `:negative_squared_cross_mark: There's nothing playing at the moment!`; - if (this.player.playbackPaused) return ":negative_squared_cross_mark: Already paused. Use the `resume` command to continue playing!"; - this.player.pause(); - this.emit("playback", false); - return; - } - resume() { - if (!this.player || !this.data.current) return `:negative_squared_cross_mark: There's nothing playing at the moment!`; - if (!this.player.paused) return ":negative_squared_cross_mark: Not paused. To pause, use the `pause` command!"; - this.player.resume(); - this.emit("playback", true); - return; - } - skip() { - if (!this.player || !this.data.current) return `:negative_squared_cross_mark: There's nothing playing at the moment!`; - this.player.stop(); - this.emit("update", "queue"); - return; - } - clear() { - this.data.queue.length = 0; - this.emit("update", "queue"); - } - getCurrent() { - if (!this.data.current) return "There's nothing playing at the moment."; - return this.getVidName(this.data.current); - } - - // utility commands - getVidName(vid, code = false) { - if (vid.type === "radio") { - if (code) { - return "[Radio]: " + vid.title + " - " + vid.author.url + ""; - } - return "[Radio] [" + vid.title + " by " + vid.author.name + "](" + vid.author.url + ")"; - } - if (vid.type === "external") { - if (code) return vid.title + " - " + vid.url; - return "[" + vid.title + "](" + vid.url + ")"; - } - if (code) return vid.title + " (" + this.getCurrentElapsedDuration() + "/" + this.getDuration(vid.duration) + ")" + ((vid.spotifyUrl || vid.url) ? " - " + (vid.spotifyUrl || vid.url) : ""); - return "[" + vid.title + " (" + this.getCurrentElapsedDuration() + "/" + this.getDuration(vid.duration) + ")" + "]" + ((vid.spotifyUrl || vid.url) ? "(" + (vid.spotifyUrl || vid.url) + ")" : ""); - } - msgChunking(msg) { - let msgs = [[""]]; - let c = 0; - msg.split("\n").forEach((line) => { - let tmp = msgs[c].slice(); - tmp.push(line); - if ((tmp.join("") + "\n").length < this.REVOLT_CHAR_LIMIT) { - msgs[c].push(line + "\n"); - } else { - msgs[++c] = [line + "\n"]; - } - }); - //msgs = msgs.map(msgChunks => "```" + msgChunks.join("") + "```"); - return msgs; - } - listQueue() { - var text = ""; - if (this.data.current) text += "[x] " + this.getVidName(this.data.current, true) + "\n"; - this.data.queue.forEach((vid, i) => { - text += "[" + i + "] " + this.getVidName(vid, true) + "\n"; - }); - if (this.data.queue.length == 0 && !this.data.current) text += "--- Empty ---"; - return text; - } - list() { - return this.listQueue(); - } - getQueue() { - return this.data.queue.map(el => { - if (el.type !== "radio") return el; - - const e = { ...el }; - e.url = e.author.url; - e.duration = { - timestamp: "infinite", - duration: Infinity - }; - return e; - }); - } - async lyrics() { - if (!this.data.current) return []; - const results = await this.gClient.songs.search(this.data.current.title); - return (!results[0]) ? null : await results[0].lyrics(); - } - loop(choice) { - if (!["song", "queue"].includes(choice)) return "'" + choice + "' is not a valid option. Valid are: `song`, `queue`"; - let name = choice.charAt(0).toUpperCase() + choice.slice(1); - - var toggle = (varName, name) => { - let variable = this.data[varName]; - this.data[varName] = 1 - variable; // toggle boolean state - variable = this.data[varName]; - return (variable) ? name + " loop activated" : name + " loop deactivated"; - } - return toggle((choice == "song") ? "loopSong" : "loop", name); - } - remove(index) { - if (!index && index != 0) throw "Index can't be empty"; - if (!this.data.queue[index]) return "Index out of bounds"; - let title = this.data.queue[index].title; - this.emit("queue", { - type: "remove", - data: { - index: index, - old: this.data.queue.slice(), - removed: this.data.queue.splice(index, 1), - new: this.data.queue - } - }); - this.emit("update", "queue"); - return "Successfully removed **" + title + "** from the queue."; - } - getDuration(duration) { - if (typeof duration === "object") { - return duration.timestamp; - } else { - return this.prettifyMS(duration); - } - } - getCurrentDuration() { - return this.getDuration(this.data.current.duration); - } - getCurrentElapsedDuration() { - return this.getDuration(this.player.seconds * 1000); - } - async nowPlaying() { - if (!this.data.current) return { msg: "There's nothing playing at the moment." }; - - let loopqueue = (this.data.loop) ? "**enabled**" : "**disabled**"; - let songloop = (this.data.loopSong) ? "**enabled**" : "**disabled**"; - const vol = ((this.connection?.preferredVolume || 1) * 100) + "%"; - const paused = !!this.connection?.media.paused; // TODO: integrate - if (this.data.current.type === "radio") { - const data = await meta(this.data.current.url); - return { msg: "Streaming **[" + this.data.current.title + "](" + this.data.current.author.url + ")**\n\n" + this.data.current.description + " \n\n### Current song: " + data.title + "\n\nVolume: " + vol + "\n\nQueue loop: " + loopqueue + "\nSong loop: " + songloop, image: await this.uploadThumbnail() } - } - if (this.data.current.type === "external") { - return { msg: "Playing **[" + this.data.current.title + "](" + this.data.current.url + ") by [" + this.data.current.artist + "](" + this.data.current.author.url + ")** \n\nVolume: " + vol + "\n\nQueue loop: " + loopqueue + "\nSong loop: " + songloop, image: await this.uploadThumbnail() } - } - return { msg: "Playing: **[" + this.data.current.title + "](" + (this.data.current.spotifyUrl || this.data.current.url) + ")** (" + this.getCurrentElapsedDuration() + "/" + this.getCurrentDuration() + ")" + "\n\nVolume: " + vol + "\n\nQueue loop: " + loopqueue + "\nSong loop: " + songloop, image: await this.uploadThumbnail() }; - } - uploadThumbnail() { - return new Promise((res) => { - //return res(); - const https = require("https"); - if (!this.data.current) return res(null); - if (!this.data.current.thumbnail) return res(null); - https.get(this.data.current.thumbnail, async (response) => { - res(await this.upload.upload(response, this.data.current.title)); - }); - }); - } - getThumbnail() { - return new Promise(async (res) => { - if (!this.data.current) return res({ msg: "There's nothing playing at the moment.", image: null }); - if (!this.data.current.thumbnail) return res({ msg: "The current media resource doesn't have a thumbnail.", image: null }); - res({ msg: `The thumbnail of the video [${this.data.current.title}](${this.data.current.url}): `, image: await this.uploadThumbnail() }); - }); - } - setVolume(v) { - if (!this.voice || !this.connection) return "Not connected to a voice channel."; - - const connection = this.voice.getVoiceConnection(this.connection.channelId); - if (!connection) return "Not connected!"; - - this.connection.preferredVolume = v; - if (connection.media) connection.media.setVolume(v); - - this.emit("volume", v); - - return "Volume changed to `" + (v * 100) + "%`."; - } - announceSong(s) { - if (!s) return; - if (s.type === "radio") { - this.emit("message", "Now streaming _" + s.title + "_ by [" + s.author.name + "](" + s.author.url + ")"); - return; - } - var author = (!s.artists) ? "[" + s.author.name + "](" + s.author.url + ")" : s.artists.map(a => `[${a.name}](${a.url})`).join(" & "); - this.emit("message", "Now playing [" + s.title + "](" + (s.spotifyUrl || s.url) + ") by " + author); - } - - // functional core - async streamResource(url) { - const axios = require('axios'); - const response = await axios({ method: 'get', url: url, responseType: 'stream' }); - return response.data; - } - - async getYoutubeiStream(videoId) { - try { - const innertube = this.innertube; - // Try TV and ANDROID clients as they have less strict PoToken requirements currently - const clients = ["TV", "ANDROID", "YTMUSIC", "WEB"]; - let webStream = null; - let lastErr = null; - - for (const client of clients) { - try { - webStream = await innertube.download(videoId, { type: "audio", quality: "best", client }); - console.log("[Player] youtubei.js stream acquired via client:", client); - break; - } catch (e) { - console.warn(`[Player] client ${client} failed:`, e.message); - lastErr = e; - } - } - - if (!webStream) throw lastErr; - - const passThrough = new PassThrough(); - const reader = webStream.getReader(); - (async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { passThrough.end(); break; } - passThrough.write(value); - } - } catch (e) { - passThrough.destroy(e); - } - })(); - return passThrough; - } catch (err) { - console.error("[Player] youtubei.js fallback failed:", err.message); - return null; - } - } - - async playNext() { - if (this.data.queue.length === 0 && !this.data.loopSong) { - this.data.current = null; - this.emit("stopplay"); - return false; - } - - const current = this.data.current; - const songData = (this.data.loopSong && current) ? current : this.data.queue.shift(); - if (current && this.data.loop && !this.data.loopSong) this.data.queue.push(current); - - if (!this.data.loopSong) { - this.emit("queue", { - type: "update", - data: { current: songData, old: current, loop: this.data.loop } - }); - } - - this.data.current = songData; - const connection = this.voice.getVoiceConnection(this.connection.channelId); - - let stream; - if (songData.type == "soundcloud") { - let ytdlpPath = (typeof this.ytdlp === "string") ? this.ytdlp : (this.ytdlp?.binaryPath || "yt-dlp"); - const proc = spawn(ytdlpPath, ["-f", "bestaudio/best", "--no-playlist", "-o", "-", "--quiet", songData.url]); - stream = proc.stdout; - } else if (songData.type == "external" || songData.type == "radio") { - stream = await this.streamResource(songData.url); - } else { - const videoId = songData.videoId || (songData.url && ( - (songData.url.match(/[?&]v=([^&]{11})/) || [])[1] || - (songData.url.match(/youtu\.be\/([^?]{11})/) || [])[1] - )); - - if (this.ytdlp) { - console.log("[Player] Attempting yt-dlp for:", videoId); - let ytdlpPath = (typeof this.ytdlp === "string") ? this.ytdlp : (this.ytdlp.binaryPath || "yt-dlp"); - - const proc = spawn(ytdlpPath, [ - "--cookies", "/root/revolt/cookies.txt", - "--js-runtimes", "node", - "-f", "251/250/249/bestaudio", - "--no-playlist", "-o", "-", "--quiet", "--no-cache-dir", "--force-ipv4", - "https://www.youtube.com/watch?v=" + videoId - ]); - - const passThrough = new PassThrough(); - stream = passThrough; - let ytdlpFallbackTriggered = false; - - proc.stdout.pipe(passThrough); - - proc.stderr.on("data", async (d) => { - if (ytdlpFallbackTriggered) return; - const msg = d.toString(); - // catches any auth/block variant YouTube may send - const isBlocked = ( - msg.includes("Sign in") || - msg.includes("bot") || - msg.includes("HTTP Error 403") || - msg.includes("HTTP Error 429") || - msg.includes("Precondition") || - msg.includes("This video is not available") || - msg.includes("blocked") || - msg.includes("login") || - msg.includes("Private video") || - msg.includes("Video unavailable") - ); - if (isBlocked) { - ytdlpFallbackTriggered = true; - console.warn("[Player] yt-dlp blocked. Switching to youtubei.js..."); - proc.stdout.unpipe(passThrough); - proc.kill(); - const fallback = await this.getYoutubeiStream(videoId); - if (fallback) { - fallback.pipe(passThrough); - } else { - passThrough.destroy(new Error("Both yt-dlp and youtubei.js failed")); - } - } - }); - - // yt-dlp exits non-zero but stderr didn't match any known pattern - proc.on("close", async (code) => { - if (ytdlpFallbackTriggered) return; - if (code !== 0 && !passThrough.destroyed) { - ytdlpFallbackTriggered = true; - console.warn("[Player] yt-dlp exited with code", code, " falling back to youtubei.js..."); - const fallback = await this.getYoutubeiStream(videoId); - if (fallback) { - fallback.pipe(passThrough); - } else { - passThrough.destroy(new Error("Both yt-dlp and youtubei.js failed")); - } - } - }); - } else { - stream = await this.getYoutubeiStream(videoId); - } - } - - if (!stream) { this.emit("stopplay"); return false; } - - connection.media.once("startplay", () => this.emit("streamStartPlay")); - connection.media.playStream(stream); - stream.once("data", () => this.startedPlaying = Date.now()); - if (this.connection.preferredVolume) connection.media.setVolume(this.connection.preferredVolume); - this.announceSong(this.data.current); - this.emit("startplay", this.data.current); - } - leave() { - if (!this.connection || !Revoice || !Revoice.State) { - return false; - } - - try { - if (this.connection.state !== Revoice.State.OFFLINE) { - const channelKey = this.connection.channelId; - this.connection.state = Revoice.State.OFFLINE; - this.leaving = true; - this.connection.leave(); - this.voice.connections.delete(channelKey); - this.data = { - queue: [], - current: null, - loop: false, - loopSong: false - }; // data should not be used after leaving, the Player object is invalidated. - } - } catch (error) { - return false; - } - - this.emit("leave"); - return true; - } - destroy() { - return this.connection.destroy(); - } - fetchResults(query, id, provider = "yt") { // TODO: implement pagination of further results - const providerNames = { - yt: "YouTube", - ytm: "YouTube Music", - scld: "SoundCloud", - }; - return new Promise(res => { - let list = `Search results using **${providerNames[provider] || "YouTube"}**:\n\n`; - this.workerJob("searchResults", { query: query, provider: provider, resultCount: this.resultLimit }, () => { }).then((data) => { - data.data.forEach((v, i) => { - const url = v.url || v.permalink_url || ""; - const title = v.title || v.name || "Unknown"; - const dur = v.duration ? this.getDuration(v.duration) : "?:??"; - list += `${i + 1}. [${title}](${url}) - ${dur}\n`; - }); - list += "\nSend the number of the result you'd like to play here in this channel. Example: `2`\nTo cancel this process, just send an 'x'!"; - this.searches.set(id, data.data); - res({ m: list, count: data.data.length }); - }); - }); - } - playResult(id, result = 0, next = false) { - if (!this.searches.has(id)) return null; - const res = this.searches.get(id)[result]; - - let prep = this.preparePlay(); - if (prep) return prep; - - this.addToQueue(res, next); - if (!this.data.current) this.playNext(); - return res; - } - join(channel) { - return new Promise(res => { - console.log("channel: ", channel) - this.voice.join(channel, this.LEAVE_TIMEOUT).then((connection) => { - //console.log(connection); - this.connection = connection; - connection.once("join", res); - var roomFetched = false; - connection.on("roomfetched", () => { if (roomFetched) return; roomFetched = true; this.emit("roomfetched", connection.users) }); - this.connection.on("state", (state) => { - console.log(state); - if (state == Revoice.State.IDLE && !roomFetched) { - this.emit("roomfetched", connection.users) - } - this.state = state; - if (state == Revoice.State.OFFLINE && !this.leaving) { - this.emit("autoleave"); - return; - } - if (state == Revoice.State.IDLE) this.playNext(); - }); - }); - }) - } - playRadio(radio, top = false) { - let prep = this.preparePlay(); - if (prep) return prep; - - const url = radio.url; - const name = radio.detailedName; - const description = radio.description; - const thumbnail = radio.thumbnail; - - this.addToQueue({ - type: "radio", - - title: name, - description, - url, - author: { - name: radio.author.name, - url: radio.author.url - }, - thumbnail, - }, top); - - if (!this.data.current) this.playNext(); - } - preparePlay() { - if (this.connection.state == Revoice.State.OFFLINE) return "Please let me join first."; - if (!this.connection.media) { - let p = new MediaPlayer(false, this.port); - this.player = p; - this.connection.play(p); - } - } - playFirst(query, provider) { - return this.play(query, true, provider); - } - play(query, top = false, provider) { // top: where to add the results in the queue (top/bottom) - let prep = this.preparePlay(); - if (prep) return prep; - - const events = new EventEmitter(); - this.workerJob("generalQuery", { query: query, spotify: this.spotifyConfig, provider: provider }, (msg) => { - events.emit("message", msg); - }).then((data) => { - if (data.type == "list") { - data.data.forEach(vid => { - this.addToQueue(vid, top); - }); - } else if (data.type == "video") { - this.addToQueue(data.data, top); - } else { - console.log("Unknown case: ", data.type, data); - } - if (!this.data.current) this.playNext(); - }).catch(reason => { - console.log("reason", reason); - reason = reason || "An error occured. Please contact the support if this happens reocurringly."; - events.emit("message", reason); - }); - return events; - } -} - -module.exports = RevoltPlayer; +const EventEmitter = require("events"); +const { Revoice, MediaPlayer } = require("revoice.js"); +const Spotify = require("spotifydl-core").default; +const Genius = require("genius-lyrics"); +const Uploader = require("revolt-uploader"); +const { Worker } = require('worker_threads'); +const { PassThrough } = require("stream"); +const { spawn } = require("child_process"); +const meta = require("./src/probe.js"); +const fs = require('fs'); + +class RevoltPlayer extends EventEmitter { + constructor(token, opts) { + super(); + + this.voice = opts.voice || new Revoice(token, undefined, opts.client); + this.connection = { state: Revoice.State.OFFLINE }; + + this.upload = opts.uploader || new Uploader(opts.client, true); + + this.spotify = opts.spotifyClient || new Spotify(opts.spotify); + this.spotifyConfig = opts.spotify; + + this.ytdlp = opts.ytdlp; + this.innertube = opts.innertube; + + this.gClient = opts.geniusClient || new (require("genius-lyrics")).Client(); + this.port = 3050 + (opts.portOffset || 0); + this.updateHandler = (content, msg) => { msg.edit({ content: content }); } + this.messageChannel = opts.messageChannel; + this.LEAVE_TIMEOUT = opts.lTimeout || 45; + this.YT_API_KEY = opts.ytKey; + this.token = token; + this.REVOLT_CHAR_LIMIT = 1950; + this.resultLimit = 5; + this.startedPlaying = null; + this.searches = new Map(); + this.data = { + queue: [], + current: null, + loop: false, + loopSong: false + }; + + return this; + } + setUpdateHandler(handler) { + this.updateHandler = handler; + } + workerJob(jobId, data, onMessage = null, msg = null) { + return new Promise((res, rej) => { + const worker = new Worker('./worker.js', { workerData: { jobId, data } }); + worker.on("message", (data) => { + data = JSON.parse(data); + if (data.event == "error") { + rej(data.data); + } else if (data.event == "message" && (msg || onMessage)) { + if (msg) this.updateHandler(data.data, msg); + if (onMessage) onMessage(data.data); + } else if (data.event == "finished") { + res(data.data); + } + }); + worker.on("exit", (code) => { if (code == 0) rej(code) }); + }); + } + guid() { + var S4 = function () { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + }; + return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); + } + + shuffleArr(a) { + var j, x, i; + for (i = a.length - 1; i > 0; i--) { + j = Math.floor(Math.random() * (i + 1)); + x = a[i]; + a[i] = a[j]; + a[j] = x; + } + return a; + } + addIdToQueue(id) { + return new Promise((res, _rej) => { + this.workerJob("search", id).then((data) => { + this.emit("queue", { + type: "add", + data: { + append: true, + data + } + }); + this.data.queue.push(data); + res(true); + }).catch(res(false)); + }); + } + addToQueue(data, top = false) { + this.emit("queue", { + type: "add", + data: { + append: !top, + data + } + }); + if (!top) return this.data.queue.push(data); + return this.data.queue.unshift(data); + } + prettifyMS(milliseconds) { + if (!milliseconds || isNaN(milliseconds) || milliseconds < 0) return "0:00"; + return new Date(milliseconds).toISOString().slice( + // if 1 hour passed, show the hour component, + // if 1 hour hasn't passed, don't show the hour component + milliseconds > 3600000 ? 11 : 14, + 19 + ); + } + + // music controls + shuffle() { + if (this.data.queue.length == 0) return "There is nothing to shuffle in the queue."; + this.data.queue = this.shuffleArr(this.data.queue); + this.emit("queue", { + type: "shuffle", + data: this.data.queue + }); + return; + } + get paused() { + if (!this.player) return false; + return this.player.playbackPaused || false; + } + pause() { + if (!this.player || !this.data.current) return `:negative_squared_cross_mark: There's nothing playing at the moment!`; + if (this.player.playbackPaused) return ":negative_squared_cross_mark: Already paused. Use the `resume` command to continue playing!"; + this.player.pause(); + this.emit("playback", false); + return; + } + resume() { + if (!this.player || !this.data.current) return `:negative_squared_cross_mark: There's nothing playing at the moment!`; + if (!this.player.paused) return ":negative_squared_cross_mark: Not paused. To pause, use the `pause` command!"; + this.player.resume(); + this.emit("playback", true); + return; + } + skip() { + if (!this.player || !this.data.current) return `:negative_squared_cross_mark: There's nothing playing at the moment!`; + this.player.stop(); + this.emit("update", "queue"); + return; + } + clear() { + this.data.queue.length = 0; + this.emit("update", "queue"); + } + getCurrent() { + if (!this.data.current) return "There's nothing playing at the moment."; + return this.getVidName(this.data.current); + } + + // utility commands + getVidName(vid, code = false) { + if (vid.type === "radio") { + if (code) { + return "[Radio]: " + vid.title + " - " + vid.author.url + ""; + } + return "[Radio] [" + vid.title + " by " + vid.author.name + "](" + vid.author.url + ")"; + } + if (vid.type === "external") { + if (code) return vid.title + " - " + vid.url; + return "[" + vid.title + "](" + vid.url + ")"; + } + if (code) return vid.title + " (" + this.getCurrentElapsedDuration() + "/" + this.getDuration(vid.duration) + ")" + ((vid.spotifyUrl || vid.url) ? " - " + (vid.spotifyUrl || vid.url) : ""); + return "[" + vid.title + " (" + this.getCurrentElapsedDuration() + "/" + this.getDuration(vid.duration) + ")" + "]" + ((vid.spotifyUrl || vid.url) ? "(" + (vid.spotifyUrl || vid.url) + ")" : ""); + } + msgChunking(msg) { + let msgs = [[""]]; + let c = 0; + msg.split("\n").forEach((line) => { + let tmp = msgs[c].slice(); + tmp.push(line); + if ((tmp.join("") + "\n").length < this.REVOLT_CHAR_LIMIT) { + msgs[c].push(line + "\n"); + } else { + msgs[++c] = [line + "\n"]; + } + }); + //msgs = msgs.map(msgChunks => "```" + msgChunks.join("") + "```"); + return msgs; + } + listQueue() { + var text = ""; + if (this.data.current) text += "[x] " + this.getVidName(this.data.current, true) + "\n"; + this.data.queue.forEach((vid, i) => { + text += "[" + i + "] " + this.getVidName(vid, true) + "\n"; + }); + if (this.data.queue.length == 0 && !this.data.current) text += "--- Empty ---"; + return text; + } + list() { + return this.listQueue(); + } + getQueue() { + return this.data.queue.map(el => { + if (el.type !== "radio") return el; + + const e = { ...el }; + e.url = e.author.url; + e.duration = { + timestamp: "infinite", + duration: Infinity + }; + return e; + }); + } + async lyrics() { + if (!this.data.current) return []; + const results = await this.gClient.songs.search(this.data.current.title); + return (!results[0]) ? null : await results[0].lyrics(); + } + loop(choice) { + if (!["song", "queue"].includes(choice)) return "'" + choice + "' is not a valid option. Valid are: `song`, `queue`"; + let name = choice.charAt(0).toUpperCase() + choice.slice(1); + + var toggle = (varName, name) => { + let variable = this.data[varName]; + this.data[varName] = 1 - variable; // toggle boolean state + variable = this.data[varName]; + return (variable) ? name + " loop activated" : name + " loop deactivated"; + } + return toggle((choice == "song") ? "loopSong" : "loop", name); + } + remove(index) { + if (!index && index != 0) throw "Index can't be empty"; + if (!this.data.queue[index]) return "Index out of bounds"; + let title = this.data.queue[index].title; + this.emit("queue", { + type: "remove", + data: { + index: index, + old: this.data.queue.slice(), + removed: this.data.queue.splice(index, 1), + new: this.data.queue + } + }); + this.emit("update", "queue"); + return "Successfully removed **" + title + "** from the queue."; + } + getDuration(duration) { + if (typeof duration === "object") { + return duration.timestamp; + } else { + return this.prettifyMS(duration); + } + } + getCurrentDuration() { + return this.getDuration(this.data.current.duration); + } + getCurrentElapsedDuration() { + return this.getDuration(this.player.seconds * 1000); + } + async nowPlaying() { + if (!this.data.current) return { msg: "There's nothing playing at the moment." }; + + let loopqueue = (this.data.loop) ? "**enabled**" : "**disabled**"; + let songloop = (this.data.loopSong) ? "**enabled**" : "**disabled**"; + const vol = ((this.connection?.preferredVolume || 1) * 100) + "%"; + const paused = !!this.connection?.media.paused; // TODO: integrate + if (this.data.current.type === "radio") { + const data = await meta(this.data.current.url); + return { msg: "Streaming **[" + this.data.current.title + "](" + this.data.current.author.url + ")**\n\n" + this.data.current.description + " \n\n### Current song: " + data.title + "\n\nVolume: " + vol + "\n\nQueue loop: " + loopqueue + "\nSong loop: " + songloop, image: await this.uploadThumbnail() } + } + if (this.data.current.type === "external") { + return { msg: "Playing **[" + this.data.current.title + "](" + this.data.current.url + ") by [" + this.data.current.artist + "](" + this.data.current.author.url + ")** \n\nVolume: " + vol + "\n\nQueue loop: " + loopqueue + "\nSong loop: " + songloop, image: await this.uploadThumbnail() } + } + return { msg: "Playing: **[" + this.data.current.title + "](" + (this.data.current.spotifyUrl || this.data.current.url) + ")** (" + this.getCurrentElapsedDuration() + "/" + this.getCurrentDuration() + ")" + "\n\nVolume: " + vol + "\n\nQueue loop: " + loopqueue + "\nSong loop: " + songloop, image: await this.uploadThumbnail() }; + } + uploadThumbnail() { + return new Promise((res) => { + //return res(); + const https = require("https"); + if (!this.data.current) return res(null); + if (!this.data.current.thumbnail) return res(null); + https.get(this.data.current.thumbnail, async (response) => { + res(await this.upload.upload(response, this.data.current.title)); + }); + }); + } + getThumbnail() { + return new Promise(async (res) => { + if (!this.data.current) return res({ msg: "There's nothing playing at the moment.", image: null }); + if (!this.data.current.thumbnail) return res({ msg: "The current media resource doesn't have a thumbnail.", image: null }); + res({ msg: `The thumbnail of the video [${this.data.current.title}](${this.data.current.url}): `, image: await this.uploadThumbnail() }); + }); + } + setVolume(v) { + if (!this.voice || !this.connection) return "Not connected to a voice channel."; + + const connection = this.voice.getVoiceConnection(this.connection.channelId); + if (!connection) return "Not connected!"; + + this.connection.preferredVolume = v; + if (connection.media) connection.media.setVolume(v); + + this.emit("volume", v); + + return "Volume changed to `" + (v * 100) + "%`."; + } + announceSong(s) { + if (!s) return; + if (s.type === "radio") { + this.emit("message", "Now streaming _" + s.title + "_ by [" + s.author.name + "](" + s.author.url + ")"); + return; + } + var author = (!s.artists) ? "[" + s.author.name + "](" + s.author.url + ")" : s.artists.map(a => `[${a.name}](${a.url})`).join(" & "); + this.emit("message", "Now playing [" + s.title + "](" + (s.spotifyUrl || s.url) + ") by " + author); + } + + // functional core + async streamResource(url) { + const axios = require('axios'); + const response = await axios({ method: 'get', url: url, responseType: 'stream' }); + return response.data; + } + + async getYoutubeiStream(videoId) { + try { + const innertube = this.innertube; + // Try TV and ANDROID clients as they have less strict PoToken requirements currently + const clients = ["TV", "ANDROID", "YTMUSIC", "WEB"]; + let webStream = null; + let lastErr = null; + + for (const client of clients) { + try { + webStream = await innertube.download(videoId, { type: "audio", quality: "best", client }); + console.log("[Player] youtubei.js stream acquired via client:", client); + break; + } catch (e) { + console.warn(`[Player] client ${client} failed:`, e.message); + lastErr = e; + } + } + + if (!webStream) throw lastErr; + + const passThrough = new PassThrough(); + const reader = webStream.getReader(); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { passThrough.end(); break; } + passThrough.write(value); + } + } catch (e) { + passThrough.destroy(e); + } + })(); + return passThrough; + } catch (err) { + console.error("[Player] youtubei.js fallback failed:", err.message); + return null; + } + } + + async playNext() { + if (this.data.queue.length === 0 && !this.data.loopSong) { + this.data.current = null; + this.emit("stopplay"); + return false; + } + + const current = this.data.current; + const songData = (this.data.loopSong && current) ? current : this.data.queue.shift(); + if (current && this.data.loop && !this.data.loopSong) this.data.queue.push(current); + + if (!this.data.loopSong) { + this.emit("queue", { + type: "update", + data: { current: songData, old: current, loop: this.data.loop } + }); + } + + this.data.current = songData; + const connection = this.voice.getVoiceConnection(this.connection.channelId); + + let stream; + if (songData.type == "soundcloud") { + let ytdlpPath = (typeof this.ytdlp === "string") ? this.ytdlp : (this.ytdlp?.binaryPath || "yt-dlp"); + const proc = spawn(ytdlpPath, ["-f", "bestaudio/best", "--no-playlist", "-o", "-", "--quiet", songData.url]); + stream = proc.stdout; + } else if (songData.type == "external" || songData.type == "radio") { + stream = await this.streamResource(songData.url); + } else { + const videoId = songData.videoId || (songData.url && ( + (songData.url.match(/[?&]v=([^&]{11})/) || [])[1] || + (songData.url.match(/youtu\.be\/([^?]{11})/) || [])[1] + )); + + if (this.ytdlp) { + console.log("[Player] Attempting yt-dlp for:", videoId); + let ytdlpPath = (typeof this.ytdlp === "string") ? this.ytdlp : (this.ytdlp.binaryPath || "yt-dlp"); + + const proc = spawn(ytdlpPath, [ + "--cookies", "/root/revolt/cookies.txt", + "--js-runtimes", "node", + "-f", "251/250/249/bestaudio", + "--no-playlist", "-o", "-", "--quiet", "--no-cache-dir", "--force-ipv4", + "https://www.youtube.com/watch?v=" + videoId + ]); + + const passThrough = new PassThrough(); + stream = passThrough; + let ytdlpFallbackTriggered = false; + + proc.stdout.pipe(passThrough); + + proc.stderr.on("data", async (d) => { + if (ytdlpFallbackTriggered) return; + const msg = d.toString(); + // catches any auth/block variant YouTube may send + const isBlocked = ( + msg.includes("Sign in") || + msg.includes("bot") || + msg.includes("HTTP Error 403") || + msg.includes("HTTP Error 429") || + msg.includes("Precondition") || + msg.includes("This video is not available") || + msg.includes("blocked") || + msg.includes("login") || + msg.includes("Private video") || + msg.includes("Video unavailable") + ); + if (isBlocked) { + ytdlpFallbackTriggered = true; + console.warn("[Player] yt-dlp blocked. Switching to youtubei.js..."); + proc.stdout.unpipe(passThrough); + proc.kill(); + const fallback = await this.getYoutubeiStream(videoId); + if (fallback) { + fallback.pipe(passThrough); + } else { + passThrough.destroy(new Error("Both yt-dlp and youtubei.js failed")); + } + } + }); + + // yt-dlp exits non-zero but stderr didn't match any known pattern + proc.on("close", async (code) => { + if (ytdlpFallbackTriggered) return; + if (code !== 0 && !passThrough.destroyed) { + ytdlpFallbackTriggered = true; + console.warn("[Player] yt-dlp exited with code", code, " falling back to youtubei.js..."); + const fallback = await this.getYoutubeiStream(videoId); + if (fallback) { + fallback.pipe(passThrough); + } else { + passThrough.destroy(new Error("Both yt-dlp and youtubei.js failed")); + } + } + }); + } else { + stream = await this.getYoutubeiStream(videoId); + } + } + + if (!stream) { this.emit("stopplay"); return false; } + + connection.media.once("startplay", () => this.emit("streamStartPlay")); + connection.media.playStream(stream); + stream.once("data", () => this.startedPlaying = Date.now()); + if (this.connection.preferredVolume) connection.media.setVolume(this.connection.preferredVolume); + this.announceSong(this.data.current); + this.emit("startplay", this.data.current); + } + leave() { + if (!this.connection || !Revoice || !Revoice.State) { + return false; + } + + try { + if (this.connection.state !== Revoice.State.OFFLINE) { + const channelKey = this.connection.channelId; + this.connection.state = Revoice.State.OFFLINE; + this.leaving = true; + this.connection.leave(); + this.voice.connections.delete(channelKey); + this.data = { + queue: [], + current: null, + loop: false, + loopSong: false + }; // data should not be used after leaving, the Player object is invalidated. + } + } catch (error) { + return false; + } + + this.emit("leave"); + return true; + } + destroy() { + return this.connection.destroy(); + } + fetchResults(query, id, provider = "yt") { // TODO: implement pagination of further results + const providerNames = { + yt: "YouTube", + ytm: "YouTube Music", + scld: "SoundCloud", + }; + return new Promise(res => { + let list = `Search results using **${providerNames[provider] || "YouTube"}**:\n\n`; + this.workerJob("searchResults", { query: query, provider: provider, resultCount: this.resultLimit }, () => { }).then((data) => { + data.data.forEach((v, i) => { + const url = v.url || v.permalink_url || ""; + const title = v.title || v.name || "Unknown"; + const dur = v.duration ? this.getDuration(v.duration) : "?:??"; + list += `${i + 1}. [${title}](${url}) - ${dur}\n`; + }); + list += "\nSend the number of the result you'd like to play here in this channel. Example: `2`\nTo cancel this process, just send an 'x'!"; + this.searches.set(id, data.data); + res({ m: list, count: data.data.length }); + }); + }); + } + playResult(id, result = 0, next = false) { + if (!this.searches.has(id)) return null; + const res = this.searches.get(id)[result]; + + let prep = this.preparePlay(); + if (prep) return prep; + + this.addToQueue(res, next); + if (!this.data.current) this.playNext(); + return res; + } + join(channel) { + return new Promise(res => { + console.log("channel: ", channel) + console.log(this.LEAVE_TIMEOUT); + this.voice.join(channel, this.LEAVE_TIMEOUT).then((connection) => { + //console.log(connection); + this.connection = connection; + connection.once("join", res); + var roomFetched = false; + connection.on("roomfetched", () => { if (roomFetched) return; roomFetched = true; this.emit("roomfetched", connection.users) }); + this.connection.on("state", (state) => { + console.log(state); + if (state == Revoice.State.IDLE && !roomFetched) { + this.emit("roomfetched", connection.users) + } + this.state = state; + if (state == Revoice.State.OFFLINE && !this.leaving) { + this.emit("autoleave"); + return; + } + if (state == Revoice.State.IDLE) this.playNext(); + }); + }); + }) + } + playRadio(radio, top = false) { + let prep = this.preparePlay(); + if (prep) return prep; + + const url = radio.url; + const name = radio.detailedName; + const description = radio.description; + const thumbnail = radio.thumbnail; + + this.addToQueue({ + type: "radio", + + title: name, + description, + url, + author: { + name: radio.author.name, + url: radio.author.url + }, + thumbnail, + }, top); + + if (!this.data.current) this.playNext(); + } + preparePlay() { + if (this.connection.state == Revoice.State.OFFLINE) return "Please let me join first."; + if (!this.connection.media) { + let p = new MediaPlayer(false, this.port); + this.player = p; + this.connection.play(p); + } + } + playFirst(query, provider) { + return this.play(query, true, provider); + } + play(query, top = false, provider) { // top: where to add the results in the queue (top/bottom) + let prep = this.preparePlay(); + if (prep) return prep; + + const events = new EventEmitter(); + this.workerJob("generalQuery", { query: query, spotify: this.spotifyConfig, provider: provider }, (msg) => { + events.emit("message", msg); + }).then((data) => { + if (data.type == "list") { + data.data.forEach(vid => { + this.addToQueue(vid, top); + }); + } else if (data.type == "video") { + this.addToQueue(data.data, top); + } else { + console.log("Unknown case: ", data.type, data); + } + if (!this.data.current) this.playNext(); + }).catch(reason => { + console.log("reason", reason); + reason = reason || "An error occured. Please contact the support if this happens reocurringly."; + events.emit("message", reason); + }); + return events; + } +} + +module.exports = RevoltPlayer; diff --git a/commands/play.js b/commands/.play.js similarity index 91% rename from commands/play.js rename to commands/.play.js index 8e00e65..f014635 100644 --- a/commands/play.js +++ b/commands/.play.js @@ -20,10 +20,10 @@ module.exports = { const p = await this.getPlayer(message); if (!p) return; const query = data.get("query").value; - message.reply(this.em("Searching...", message), false).then((msg) => { + message.replyEmbed("Searching...").then((msg) => { const messages = p.play(query, false, data.get("provider").value); messages.on("message", (d) => { - msg.edit(this.em(d, message)); + msg.editEmbed(d); }); }); } diff --git a/commands/clear.js b/commands/clear.js index acfeca7..122fedc 100644 --- a/commands/clear.js +++ b/commands/clear.js @@ -9,6 +9,6 @@ module.exports = { const p = await this.getPlayer(msg); if (!p) return; p.clear(); - msg.channel.sendMessage(this.em("✅ Queue cleared.", msg)); + msg.channel.sendEmbed("✅ Queue cleared."); } } diff --git a/commands/dashboard.js b/commands/dashboard.js new file mode 100644 index 0000000..0325896 --- /dev/null +++ b/commands/dashboard.js @@ -0,0 +1,12 @@ +const { CommandBuilder } = require("../Commands.js"); + +module.exports = { + command: new CommandBuilder() // TODO: maybe move to own website category? + .setName("dashboard") + .setDescription("Display information about the dashboard.") + .setCategory("util"), + run: async function (msg, data) { // TODO: temporary login (without creating account) + const url = this.config.dashboardUrl; + msg.reply(this.em("## Dashboard\n\nThe Dashboard is accessible under [" + url + "](" + url + "). Please note that it is very early and experimental version and is thus subject to many bugs.\n\nAccessing the actual dashboard will require you to connect to a Stoat account. However, this does not require access to your actual Stoat login information. You will have to provide your User ID or your Username + Discriminator. Afterwards you'll have to confirm your identity by sending a command with a certain token to Remix, verifying you. That is all that will happen and none of your data passes our servers.", msg), false); + } +} diff --git a/commands/debug.js b/commands/debug.js index 70882d9..95a598d 100644 --- a/commands/debug.js +++ b/commands/debug.js @@ -14,7 +14,7 @@ module.exports = { switch(data.get("target").value) { case "voice": var servers = []; - var iterator = this.playerMap.entries(); + var iterator = this.players.playerMap.entries(); for (let v = iterator.next(); !v.done; v = iterator.next()) { servers.push(v.value[1]); }; diff --git a/commands/forceleave.js b/commands/forceleave.js index 4ad42fc..66d7e76 100644 --- a/commands/forceleave.js +++ b/commands/forceleave.js @@ -14,10 +14,10 @@ module.exports = { ), run: async function(msg, data) { const cid = data.get("channelId").value; - if (msg.channel.server.id !== this.client.channels.get(cid)?.server.id) return msg.reply(this.em("This command has to be run in the same server as the voice channel.", msg), false) - const p = this.playerMap.get(cid); - if (!p) return msg.reply(this.em("Player not found", msg)); - if (!p.connection) return msg.reply(this.em("Player not initialized.", msg), false); - this.leaveChannel.call(this, msg, cid, p); + if (msg.channel.server.id !== this.client.channels.get(cid)?.server.id) return msg.replyEmbed("This command has to be run in the same server as the voice channel."); + const p = this.players.playerMap.get(cid); + if (!p) return msg.replyEmbed("Player not found"); + if (!p.connection) return msg.replyEmbed("Player not initialized."); + this.players.leave(msg, cid); } } diff --git a/commands/join.js b/commands/join.js index 31e9045..c1076bf 100644 --- a/commands/join.js +++ b/commands/join.js @@ -1,15 +1,15 @@ const { CommandBuilder } = require("../Commands.js"); const RevoltPlayer = require("../Player.js"); - +/* */ function joinChannel(message, cid, cb=()=>{}, ecb=()=>{}) { if (!this.client.channels.has(cid)) { ecb(); - return message.reply(this.em("Couldn't find the channel `" + cid + "`\nUse the help command to learn more about this. (`%help join`)", message), false) + return message.replyEmbed("Couldn't find the channel `" + cid + "`\nUse the help command to learn more about this. (`%help join`)") } if (this.playerMap.has(cid)) { cb(this.playerMap.get(cid)); - return message.reply(this.em("Already joined <#" + cid + ">.", message), false); + return message.replyEmbed("Already joined <#" + cid + ">."); } this.channels.push(cid); const settings = this.getSettings(message); @@ -28,7 +28,7 @@ function joinChannel(message, cid, cb=()=>{}, ecb=()=>{}) { innertube: this.innertube }); p.on("autoleave", async () => { - message.channel.sendMessage(this.em("Left channel <#" + cid + "> because of inactivity.", message)); + message.channel.sendEmbed("Left channel <#" + cid + "> because of inactivity."); const port = p.port - 3050; this.playerMap.delete(cid); p.destroy(); @@ -44,7 +44,7 @@ function joinChannel(message, cid, cb=()=>{}, ecb=()=>{}) { }); p.on("message", (m) => { if ((this.getSettings(message).get("songAnnouncements")) == "false") return; - message.channel.sendMessage(this.em(m, message)) + message.channel.sendEmbed(m); }); p.on("roomfetched", () => { p.connection.users.forEach(user => { @@ -55,9 +55,9 @@ function joinChannel(message, cid, cb=()=>{}, ecb=()=>{}) { }); }); this.playerMap.set(cid, p); - message.reply(this.em("Joining Channel...", message), false).then((message) => { + message.replyEmbed("Joining Channel...").then((message) => { p.join(cid).then(() => { - message.edit(this.em(`✅ Successfully joined <#${cid}>`, message)); + message.editEmbed(`✅ Successfully joined <#${cid}>`); cb(p); p.connection.on("userjoin", (user) => { @@ -101,7 +101,7 @@ module.exports = { ), run: function(message, data) { const cid = data.getById("cid").value || this.checkVoiceChannels(message); - joinChannel.call(this, message, cid); + this.players.initPlayer(message, cid); }, export: { name: "joinChannel", diff --git a/commands/leave.js b/commands/leave.js index 2652f64..21ad7aa 100644 --- a/commands/leave.js +++ b/commands/leave.js @@ -4,12 +4,12 @@ function leaveChannel(msg, cid, p) { return new Promise(async (res) => { this.playerMap.delete(cid); const port = p.port - 3050; - const m = await msg.reply(this.em("Leaving...", msg), false); + const m = await msg.replyEmbed("Leaving..."); const left = p.leave(); //p.leave().then(async left => { p.destroy(); // wait for the ports to be open again this.freed.push(port); - m.edit(this.em((left) ? `✅ Successfully Left` : `Not connected to any voice channel`, msg)); + m.editEmbed((left) ? `✅ Successfully Left` : `Not connected to any voice channel`); res(); }); } @@ -22,9 +22,9 @@ module.exports = { run: async function(msg) { const p = await this.getPlayer(msg, false, false); if (!p) return; - if (!p.connection) return msg.reply(this.em("Player not initialized.", msg), false); + if (!p.connection) return msg.replyEmbed("Player not initialized."); const cid = p.connection.channelId; - leaveChannel.call(this, msg, cid, p); + this.players.leave(msg, cid); }, export: { name: "leaveChannel", diff --git a/commands/loop.js b/commands/loop.js index 0e1f021..48f5585 100644 --- a/commands/loop.js +++ b/commands/loop.js @@ -13,6 +13,6 @@ module.exports = { const p = await this.getPlayer(message, data); if (!p) return; const res = p.loop(data.options[0].value); - message.channel.sendMessage(this.em(res, message)); + message.channel.sendEmbed(res); } } diff --git a/commands/lyrics.js b/commands/lyrics.js index e9ed6bd..c9dbb16 100644 --- a/commands/lyrics.js +++ b/commands/lyrics.js @@ -10,13 +10,13 @@ module.exports = { const p = await this.getPlayer(message); if (!p) return; - const n = message.reply(this.em("Fetching lyrics from genius...", message), false); + const n = message.replyEmbed("Fetching lyrics from genius..."); var messages = await p.lyrics(); - if (!messages) return (await n).edit(this.em("Couldn't find the lyrics for this song on genius!", message), false); - if (messages.length == 0) return message.reply(this.em("There's nothing playing at the moment.", message), false); + if (!messages) return (await n).editEmbed("Couldn't find the lyrics for this song on genius!"); + if (messages.length == 0) return message.replyEmbed("There's nothing playing at the moment."); messages = messages.split("\n"); - (await n).delete(); - this.pagination("Lyrics for " + p.getVidName(p.data.current) + ": \n```\n$content\n```\nPage $currPage/$maxPage", messages, message, 15) + (await n).message.delete(); + this.pagination("Lyrics for " + p.getVideoName(p.queue.getCurrent()) + ": \n```\n$content\n```\nPage $currPage/$maxPage", messages, message, 15) } } diff --git a/commands/np.js b/commands/np.js index fc1e5e5..c7c2771 100644 --- a/commands/np.js +++ b/commands/np.js @@ -8,11 +8,11 @@ module.exports = { run: async function(msg) { const p = await this.getPlayer(msg); if (!p) return; - msg.reply(this.em("Loading...", msg)).then(async m => { + msg.replyEmbed("Loading...").then(async m => { let data = await p.nowPlaying(); - let embed = this.em(data.msg, m); - if (data.image) embed.embeds[0].media = data.image; - m.edit(embed); + m.editEmbed(data.msg, { + media: data.image + }); }); } } diff --git a/commands/pause.js b/commands/pause.js index 84f260d..8d56e99 100644 --- a/commands/pause.js +++ b/commands/pause.js @@ -8,6 +8,6 @@ module.exports = { const p = await this.getPlayer(message); if (!p) return; let res = p.pause() || `✅ The song has been paused!`; - message.channel.sendMessage(this.em(res, message)); + message.channel.sendEmbed(res); } } diff --git a/commands/play.mjs b/commands/play.mjs new file mode 100644 index 0000000..22e70b7 --- /dev/null +++ b/commands/play.mjs @@ -0,0 +1,28 @@ +import { CommandBuilder } from "../src/CommandHandler.mjs"; +export const command = new CommandBuilder() + .setName("play") + .setId("play") + .setDescription("Play a youtube video from url/query or a playlist by url. Other services are supported as well.\nSearches will be done on `Youtube Music` by default.\nIf you want to search on `YouTube` you will have to specify that with `-u yt` explicitly.", "commands.play") + .addExamples("$prefixplay take over league of legends", "$prefixplay -provider yt 'take over league of legends'", "$prefixp take over league of legends") + .addTextOption((option) => + option.setName("query") + .setDescription("A YouTube query/url, playlist url, or a link to a Spotify, SoundCloud, or YouTube Music song.", "options.play.query") + .setRequired(true) + ).addChoiceOption(o => + o.setName("provider") + .setDescription("The search result provider (YouTube, YouTube Music or SoundCloud). Default: SoundCloud", "options.search.provider") // same as search provider flag + .addFlagAliases("p", "u", "use") + .addChoices("ytm", "yt", "scld") + .setDefault("ytm") + , true).addAlias("p"); +export const run = async function(message, data) { + const p = await this.getPlayer(message); + if (!p) return; + const query = data.get("query").value; + message.replyEmbed("Searching...").then((msg) => { + const messages = p.play(query, false, data.get("provider").value); + messages.on("message", (d) => { + msg.editEmbed(d); + }); + }); +} diff --git a/commands/player.js b/commands/player.js index 51c845d..f5260bc 100644 --- a/commands/player.js +++ b/commands/player.js @@ -1,47 +1,51 @@ const { CommandBuilder } = require("../Commands.js"); +const { Message } = require("../src/MessageHandler.mjs"); module.exports = { command: new CommandBuilder() .setName("player") .setDescription("Create an emoji player control for your voice channel", "commands.player"), + /** + * @param {Message} msg + */ run: async function(msg) { const p = await this.getPlayer(msg); if (!p) return; const Timeout = this.config.playerAFKTimeout || 10 * 6000; - const controls = ["▶️", "⏸️", "⏭️", "🔁", "🔀"]; const form = "Currently Playing: $current\n\n$lastMsg"; - var em = this.embedify(form.replace(/\$current/gi, p.getCurrent()).replace(/\$lastMsg/gi, "Control updates will appear here")); - msg.reply({ + var lastContent = form.replace(/\$current/gi, p.getCurrent()).replace(/\$lastMsg/gi, "Control updates will appear here"); + msg.replyEmbed({ content: " ", - embeds: [em], + embedText: lastContent, interactions: { restrict_reactions: true, reactions: controls } - }, false).then((m) => { + }).then((m) => { + var suspensionTimeout = setTimeout(() => close(), Timeout); var lastUpdate = "Control updates will appear here"; - const update = (s=lastUpdate) => { - em = this.embedify(form.replace(/\$current/gi, p.getCurrent()).replace(/\$lastMsg/gi, s)) - m.edit({ embeds: [ em ]}); + const update = (s = lastUpdate) => { + lastContent = form.replace(/\$current/gi, p.getCurrent()).replace(/\$lastMsg/gi, s); + m.editEmbed(lastContent); lastUpdate = s; } const close = () => { - this.unobserveReactions(oid); - m.edit({ + unobserve(); + m.editEmbed({ content: "Player Session Closed", - embeds: [ - this.embedify(em.description + "\n\nSession Closed. The player controls **won't respond** from here.", "red") - ] + embedText: lastContent + "\n\nSession Closed. The player controls **won't respond** from here.", + }, { + colour: "red" }); } p.on("message", () => { update(); }); - const oid = this.observeReactions(m, controls, (e, ms) => { + const unobserve = m.onReaction(controls, (e) => { var reply = ""; switch (e.emoji_id) { case controls[0]: diff --git a/commands/playnext.js b/commands/playnext.js index e043b99..a739194 100644 --- a/commands/playnext.js +++ b/commands/playnext.js @@ -19,10 +19,10 @@ module.exports = { const p = await this.getPlayer(message); if (!p) return; const query = data.get("query").value; // only 1 text option registered - message.reply(this.em("Searching...", message), false).then((msg) => { + message.replyEmbed("Searching...").then((msg) => { const messages = p.playFirst(query, data.get("provider").value); messages.on("message", (d) => { - msg.edit(this.em(d, message)); + msg.editEmbed(d, message); }); }); } diff --git a/commands/radio.js b/commands/radio.js index e549bdc..bbc7e60 100644 --- a/commands/radio.js +++ b/commands/radio.js @@ -25,7 +25,7 @@ module.exports = { m += " \n\nUse the name in the brackets to select that station in the radio command.\n\n"; m += "Example: `%radio zamrock`"; - msg.reply(this.em(m, msg), false); + msg.replyEmbed(m); return; } @@ -33,9 +33,9 @@ module.exports = { if (!p) return; const radio = this.config.radio.find(e => e.name === data.get("station").value); - msg.channel.sendMessage(this.em("Adding radio station to queue...", msg)).then(m => { + msg.channel.sendEmbed("Adding radio station to queue...").then(m => { const _messages = p.playRadio(radio); - m.edit(this.em("Added `" + radio.detailedName + "` to the queue.", msg)); + m.editEmbed("Added `" + radio.detailedName + "` to the queue."); }); } } diff --git a/commands/remove.js b/commands/remove.js index d04ae92..f10f9a9 100644 --- a/commands/remove.js +++ b/commands/remove.js @@ -13,6 +13,6 @@ module.exports = { const p = await this.getPlayer(message); if (!p) return; let res = p.remove(data.options[0].value); - message.channel.sendMessage(this.em(res, message)); + message.channel.sendEmbed(res); } } diff --git a/commands/resume.js b/commands/resume.js index 90ef5ab..81b6858 100644 --- a/commands/resume.js +++ b/commands/resume.js @@ -8,6 +8,6 @@ module.exports = { const p = await this.getPlayer(message); if (!p) return; let res = p.resume() || `✅ The song has been resumed!`; - message.channel.sendMessage(this.em(res, message)); + message.channel.sendEmbed(res); } } diff --git a/commands/search.js b/commands/search.js index b0ef0c8..11003d1 100644 --- a/commands/search.js +++ b/commands/search.js @@ -1,18 +1,22 @@ const { CommandBuilder } = require("../Commands.js"); +const { MessageHandler } = require("../src/MessageHandler.mjs"); function awaitMessage(msg, count, player) { - const oid = this.observeUser(msg.authorId, msg.channelId, (m) => { + /** @type {MessageHandler} */ + const messages = this.messages; + const channel = messages.getChannel(msg.channel.id); + const unobserve = channel.onMessageUser((m) => { if (m.content.trim().toLowerCase() == "x") { - this.unobserveUser(oid); - return m.reply(this.em("Cancelled!", m)); + unobserve(); + return m.replyEmbed("Cancelled!"); } let c = parseInt(m.content.trim().replace(/\./g, "")); - if (isNaN(c)) return m.reply(this.em("Invalid number! (Send 'x' to cancel)", m)); - if (c < 0 || c > count) return m.reply(this.em("Index out of range! (`1 - " + count + "`)", m)); + if (isNaN(c)) return m.replyEmbed("Invalid number! (Send 'x' to cancel)"); + if (c < 0 || c > count) return m.replyEmbed("Index out of range! (`1 - " + count + "`)"); let v = player.playResult(msg.authorId, c - 1); - m.reply(this.em((typeof v == "string") ? v : `Added [${v.title}](${v.url}) to the queue!`, m)); - this.unobserveUser(oid); - }); + m.replyEmbed((typeof v == "string") ? v : `Added [${v.title}](${v.url}) to the queue!`); + unobserve(); + }, msg.author); } module.exports = { @@ -36,11 +40,10 @@ module.exports = { if (!p) return; let query = data.get("query").value; let provider = data.get("provider")?.value; - msg.reply(this.em("Loading results...", msg)).then(async m => { + msg.replyEmbed("Loading results...").then(async m => { let res = await p.fetchResults(query, msg.authorId, provider); - m.edit(this.em(res.m, msg)); + m.editEmbed(res.m); awaitMessage.call(this, msg, res.count, p); }); } } - diff --git a/commands/settings.js b/commands/settings.js index 8ba7c31..24bb371 100644 --- a/commands/settings.js +++ b/commands/settings.js @@ -63,22 +63,25 @@ module.exports = { case "setSettings": var failed = false; if (runnables[data.get("setting").value]) failed = runnables[data.get("setting").value].call(this, data.get("value").value, { msg: message, d: data }) - if (failed) return message.reply(this.em(failed, message), false); + if (failed) return message.replyEmbed(failed); set.set(data.get("setting").value, data.get("value").value); - message.reply(this.em("Settings changed!", message), false); + message.replyEmbed("Settings changed!"); break; case "getSettings": - if (setting) return message.reply(this.em(`\`${setting}\` is set to \`${set.get(setting)}\``, message), false); + if (setting) return message.replyEmbed(`\`${setting}\` is set to \`${set.get(setting)}\``); const d = set.getAll(); let msg = "The settings for this server (" + message.channel.server.name + ") are as following: \n\n"; for (key in d) { msg += "- " + key + ": `" + d[key] + "`\n"; } - message.reply(this.iconem("Settings", msg.trim(), message.channel.server.iconURL, message), false); + message.replyEmbed(msg.trim(), false, { + title: "Settings", + icon_url: message.channel.server.iconURL + }); break; case "reset": set.reset(setting); - message.reply(this.em("`" + setting + "` has been reset to `" + set.get(setting) + "`.", message), false); + message.replyEmbed("`" + setting + "` has been reset to `" + set.get(setting) + "`."); break; case "help": if (!setting) { @@ -92,7 +95,7 @@ module.exports = { To display more information about an individual option, use \`$prefixsettings help