diff --git a/src/modules/music.js b/src/modules/music.js index 90e0a29..8dbf5e9 100644 --- a/src/modules/music.js +++ b/src/modules/music.js @@ -1,6 +1,10 @@ const fetch = require("node-fetch"); const ytdl = require("ytdl-core"); const Eris = require("eris"); +const ffprobe = require("node-ffprobe"); + +const Command = require("../lib/command.js"); +const {formatTime, selectionMessage} = require("../lib/utils.js"); const REGEX_YOUTUBE = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/; const REGEX_YOUTUBE_PLAYLIST = @@ -16,6 +20,7 @@ const REGEX_FILE = let SOUNDCLOUD_CLIENTID; hf.voiceStorage = hf.voiceStorage || new Eris.Collection(); +const voiceStorage = hf.voiceStorage; // https://stackoverflow.com/a/12646864 ยง "Updating to ES6 / ECMAScript 2015" function shuffleArray(array) { @@ -44,3 +49,485 @@ async function getSoundcloudClientID() { } return null; } + +async function processPlaylist(url, type, shuffle = false) { + let playlist; + + if (type === "yt") { + const playlistId = + url.match(REGEX_YOUTUBE_PLAYLIST)?.[4] ?? + url.match(REGEX_YOUTUBE_PLAYLIST_SHORT)?.[0]; + if (!playlistId) return null; + + const baseUrl = `https://www.googleapis.com/youtube/v3/playlistItems?key=${hf.apikeys.google}&part=snippet&playlistId=${playlistId}&maxResults=50`; + + const data = await fetch(baseUrl).then((res) => res.json()); + + playlist = data.items; + + let pageToken = data.nextPageToken; + while (pageToken != null) { + const pageData = await fetch(baseUrl + "&pageToken=" + pageToken).then( + (res) => res.json() + ); + if (pageData.nextPageToken) pageToken = pageData.nextPageToken; + + playlist = [...playlist, ...pageData.items]; + } + } else if (type === "sc") { + const clientId = await getSoundcloudClientID(); + + if (url.indexOf("/likes") > -1) { + const userInfo = await fetch( + `https://api-v2.soundcloud.com/resolve?url=${url}&client_id=${clientId}&limit=500` + ).then((res) => res.json()); + const likesUrl = + userInfo.uri.replace("api.", "api-v2.") + + "/likes?limit=500&client_id=" + + clientId; + + let currentLikes = await fetch(likesUrl).then((res) => res.json()); + playlist = currentLikes.collection; + + while (currentLikes.next_href != null) { + currentLikes = await fetch( + currentLikes.next_href + "&client_id=" + clientId + ).then((res) => res.json()); + playlist = [...playlist, ...currentLikes.collection]; + } + } else { + playlist = await fetch( + `https://api-v2.soundcloud.com/resolve?url=${url}&client_id=${clientId}&limit=500` + ) + .then((res) => res.json()) + .then((obj) => obj.tracks); + } + } + + if (shuffle === true && playlist != null) { + shuffleArray(playlist); + } + + return playlist; +} + +async function createVoiceConnection(guild_id, voice_id, text_id) { + const connection = await hf.bot.joinVoiceChannel(voice_id); + connection._music_text_id = text_id; + connection._music_queue = []; + + connection._music_eventEnd = async function () { + if (connection._music.queue.length > 0) { + const next = connection._music_queue.splice(0, 1); + await enqueue( + guild_id, + voice_id, + text_id, + next.url, + next.type, + next.addedBy + ); + } else { + await connection.disconnect(); + if (connection._music_leave === false) { + await hf.bot.guilds + .get(guild_id) + .channels.get(text_id) + .createMessage( + ":musical_note: Queue is empty, leaving voice channel." + ); + } + connection.off("end", connection._music_eventEnd); + voiceStorage.delete(guild_id); + } + }; + + voiceStorage.set(guild_id, connection); + + return connection; +} + +async function enqueue( + guild_id, + voice_id, + text_id, + url, + type, + addedBy, + suppress = false +) { + const connection = + voiceStorage.get(guild_id) ?? + (await createVoiceConnection(guild_id, voice_id, text_id)); + const textChannel = hf.bot.guilds.get(guild_id).channels.get(text_id); + + let title, + length, + thumbnail, + media, + stream = false; + + if (type == "yt") { + let info; + try { + info = await ytdl.getInfo(url, {}); + } catch (err) { + await textChannel.createMessage( + `:warning: Failed to get metadata: \`\`\`\n${err}\n\`\`\`` + ); + } + + title = info?.videoDetails?.title; + length = info?.videoDetails?.lengthSeconds * 1000; + thumbnail = info?.videoDetails?.thumbnails?.[ + info.videoDetails.thumbnails.length - 1 + ].url + .replace("vi_webp", "vi") + .replace(".webp", ".jpg"); + media = ytdl(url, { + quality: "highestaudio", + filter: "audioonly", + highWaterMark: 1 << 25, + }); + } else if (type == "sc") { + url = url.replace(/^sc:/, "https://soundcloud.com/"); + const client_id = await getSoundcloudClientID(); + + const info = await fetch( + `https://api-v2.soundcloud.com/resolve?url=${url}&client_id=${client_id}` + ).then((res) => res.json()); + + const formatUrl = info.media.transcodings.filter( + (obj) => !obj.snipped && obj.format.protocol == "progressive" + )[0].url; + const streamUrl = await fetch(`${formatUrl}?client_id=${client_id}`) + .then((res) => res.json()) + .then((obj) => obj.url); + + title = info.title; + length = info.duration; + thumbnail = info.artwork_url; + media = streamUrl; + } else if (type == "file") { + title = url; + let info; + + try { + info = await ffprobe(url).then((obj) => obj.format); + } catch (err) { + textChannel.createMessage( + `:warning: Failed to get metadata: \`\`\`\n${err}\n\`\`\`` + ); + } + + stream = !info.duration; + + if (info.tags) { + title = `${ + info.tags.artist ?? info.tags.album_artist ?? "" + } - ${info.tags.title ?? ""}`; + } + + length = info.duration ? Math.floor(info.duration) * 1000 : 0; + media = url; + } + + if (connection.playing) { + connection._music_queue.push({ + url, + type, + title, + length, + addedBy, + stream, + }); + if (suppress === false) { + textChannel.createMessage({ + embeds: [ + { + title: `<:ms_tick:503341995348066313> Added to queue`, + color: 0x3fdcee, + fields: [ + { + name: "Title", + value: title && title != url ? `[${title}](${url})` : url, + inline: true, + }, + { + name: "Length", + value: stream + ? "" + : length + ? formatTime(length) + : "", + inline: true, + }, + { + name: "Added by", + value: `<@${addedBy}>`, + inline: true, + }, + ], + thumbnail: { + url: thumbnail, + }, + }, + ], + }); + } + } else { + if (!media) { + textChannel.createMessage( + `:warning: No usable media was found for \`${url}\`. May possibly be due to region restrictions or paywalls.` + ); + return; + } + + await connection.play(media, {inlineVolume: true, voiceDataTimeout: -1}); + + textChannel.createMessage({ + embeds: [ + { + title: `:musical_note Now Playing`, + color: 0x18e3c8, + fields: [ + { + name: "Title", + value: title ? `[${title}](${url})` : url, + inline: true, + }, + { + name: "Length", + value: stream + ? "" + : length + ? formatTime(length) + : "", + inline: true, + }, + { + name: "Added by", + value: `<@${addedBy}>`, + inline: true, + }, + ], + thumbnail: { + url: thumbnail, + }, + }, + ], + }); + + connection._music_nowplaying = { + title, + addedBy, + thumbnail, + length, + start: Date.now(), + end: Date.now() + length, + stream, + }; + } +} + +async function youtubeSearch(msg, str) { + const items = await fetch( + `https://www.googleapis.com/youtube/v3/search?key=${ + hf.apikeys.google + }&maxResults=5&part=snippet&type=video&q=${encodeURIComponent(str)}` + ).then((res) => res.json()); + + const selection = items.map((item) => ({ + value: "https://youtu.be/" + item.id.videoId, + key: item.id.videoId, + display: `"${item.snippet.title}" from ${item.snippet.channelTitle}`, + })); + + try { + return await selectionMessage(msg, "Search results:", selection); + } catch (out) { + return out; + } +} + +const command = new Command("music"); +command.addAlias("m"); +command.category = "misc"; +command.helpText = "Music"; +command.usage = "[search term]"; +command.callback = async function (msg, line) { + if (!msg.guildID) return "This command can only be used in guilds."; + + const [subcommand, ...args] = line.split(" "); + let argStr = args.join(" "); + + switch (subcommand) { + case "play": + case "p": + if (msg.member?.voiceState?.channelID) { + if (voiceStorage.has(msg.guildID)) { + const conn = voiceStorage.get(msg.guildID); + if (conn.channelID != msg.member.voiceState.channelID) { + return "You are in a different voice channel than the bot."; + } + } + + let shuffle = false; + if (argStr.startsWith("--shuffle ")) { + shuffle = true; + argStr = argStr.replace(/^--shuffle /, ""); + } + + let type; + let playlist = false; + + if ( + REGEX_YOUTUBE_PLAYLIST.test(argStr) || + REGEX_YOUTUBE_PLAYLIST_SHORT.test(argStr) + ) { + type = "yt"; + playlist = true; + } else if (REGEX_SOUNDCLOUD_PLAYLIST.test(argStr)) { + type = "sc"; + playlist = true; + } else if (REGEX_YOUTUBE.test(argStr)) { + type = "yt"; + } else if (REGEX_SOUNDCLOUD.test(argStr)) { + type = "sc"; + } else if (REGEX_FILE.test(argStr)) { + type = "file"; + } + + if (type != null) { + if (playlist) { + const playlist = await processPlaylist(argStr, type, shuffle); + const statusMessage = msg.channel.createMessage({ + embeds: [ + { + title: + " Processing playlist...", + description: `${playlist.length} tracks`, + color: 0xff8037, + }, + ], + }); + for (const track of playlist) { + let url; + if (type == "yt") { + url = "https://youtu.be/" + track.snippet.resourceId.videoId; + } else if (type == "sc") { + url = track.track + ? track.track.permalink_url + : track.permalink_url; + } + + await enqueue( + msg.guildID, + msg.member.voiceState.channelID, + msg.channel.id, + url, + type, + msg.author.id, + true + ); + } + await statusMessage.edit({ + embeds: [ + { + title: "<:ms_tick:503341995348066313> Done processing", + description: `${playlist.length} tracks`, + color: 0xff8037, + }, + ], + }); + } else { + await enqueue( + msg.guildID, + msg.member.voiceState.channelID, + msg.channel.id, + argStr, + type, + msg.author.id + ); + } + } else { + const url = await youtubeSearch(msg, argStr); + if (url != "Canceled" && url != "Request timed out") { + await enqueue( + msg.guildID, + msg.member.voiceState.channelID, + msg.channel.id, + url, + "yt", + msg.author.id + ); + } else { + return url; + } + } + } else { + return "You are not in a voice channel."; + } + break; + case "skip": + case "s": + if (msg.member?.voiceState?.channelID) { + const connection = voiceStorage.get(msg.guildID); + if (voiceStorage.has(msg.guildID)) { + if (connection.channelID != msg.member.voiceState.channelID) { + return "You are in a different voice channel than the bot."; + } + } + + // TODO: skip lock checks + await connection.stopPlaying(); + return {reaction: "\u23ed"}; + } else { + return "You are not in a voice channel."; + } + case "leave": + case "l": + case "stop": + if (msg.member?.voiceState?.channelID) { + const connection = voiceStorage.get(msg.guildID); + if (voiceStorage.has(msg.guildID)) { + if (connection.channelID != msg.member.voiceState.channelID) { + return "You are in a different voice channel than the bot."; + } + } + + // TODO: skip lock checks + connection._music_queue = []; + connection._music_leave = true; + await connection.stopPlaying(); + await hf.bot.leaveVoiceChannel(); + return {reaction: "\uD83D\uDC4B"}; + } else { + return "You are not in a voice channel."; + } + case "np": + case "queue": + case "q": + case "remove": + case "qr": + case "lock": + case "unlock": + return "TODO"; + case "help": + case "h": + return `**__Music Subcommands__** +\u2022 \`play/p [url|search string]\` - Play or add to queue. Supports: YouTube, SoundCloud, mp3/flac/ogg/wav/webm/mp4/mov/mkv/mod/s3m/it/xm, streams +\u2022 \`queue/q (page)\` - Lists the current queue. +\u2022 \`leave/l/stop\` - Leaves the voice channel. +\u2022 \`np\` - Shows whats currently playing. +\u2022 \`skip/s\` - Skips whats currently playing. +\u2022 \`remove/qr\` - Remove an item from queue. +\u2022 \`lock\` - Lock skipping and queue. (manage messages) +\u2022 \`unlock\` - Unlock skipping and queue. (manage messages) +\u2022 \`help/h\` - This text.`; + default: + return `Invalid/missing subcommand. See \`${hf.config.prefix}music help\` for all subcommands.`; + } + return null; // shutting up eslint +}; +hf.registerCommand(command);