HiddenPhox/src/modules/music.js

787 lines
23 KiB
JavaScript

const {Collection} = require("@projectdysnomia/dysnomia");
const playdl = require("play-dl");
const ffprobe = require("node-ffprobe");
const Command = require("../lib/command.js");
const {
formatTime,
selectionMessage,
parseHtmlEntities,
} = require("../lib/utils.js");
const REGEX_YOUTUBE = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
const REGEX_YOUTUBE_PLAYLIST =
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/playlist\?list=(.+)$/;
const REGEX_YOUTUBE_PLAYLIST_SHORT = /^PL[a-zA-Z0-9-_]{1,32}$/;
const REGEX_SOUNDCLOUD =
/^((https?:\/\/)?(www\.|m\.)?soundcloud\.com\/|sc:).+\/.+$/;
const REGEX_SOUNDCLOUD_PLAYLIST =
/^((https?:\/\/)?(www\.|m\.)?soundcloud\.com\/|sc:).+\/(sets\/.+|likes|tracks)$/;
const REGEX_FILE =
/^(https?:\/\/)?.*\..*\/.+\.(mp3|ogg|flac|wav|webm|mp4|mov|mkv|mod|s3m|it|xm)$/;
let SOUNDCLOUD_CLIENTID;
hf.voiceStorage = hf.voiceStorage || new Collection();
const voiceStorage = hf.voiceStorage;
// https://stackoverflow.com/a/12646864 § "Updating to ES6 / ECMAScript 2015"
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
async function getSoundcloudClientID() {
if (SOUNDCLOUD_CLIENTID != null) {
return SOUNDCLOUD_CLIENTID;
}
const page = await fetch("https://soundcloud.com").then((res) => res.text());
const scripts = page
.match(/<script crossorigin src="(.+?)"><\/script>/g)
.reverse();
for (const script of scripts) {
const url = script.match(/src="(.+?)"/)[1];
const contents = await fetch(url).then((res) => res.text());
if (/,client_id:"(.+?)",/.test(contents)) {
const client_id = contents.match(/,client_id:"(.+?)",/)[1];
SOUNDCLOUD_CLIENTID = client_id;
return SOUNDCLOUD_CLIENTID;
}
}
return null;
}
async function processPlaylist(
url,
type,
shuffle = false,
limit = -1,
offset = 0
) {
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) {
let userInfo = await fetch(
`https://api-v2.soundcloud.com/resolve?url=${url}&client_id=${clientId}&limit=500`
).then((res) => res.json());
while (!userInfo.uri) {
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);
}
if (offset > 0) {
playlist = playlist.slice(offset);
}
if (limit > 0) {
playlist = playlist.slice(0, limit);
}
return playlist;
}
async function createVoiceConnection(guild_id, voice_id, text_id) {
const state = {
voice_id,
text_id,
};
state.connection = await hf.bot.joinVoiceChannel(voice_id, {
selfDeaf: true,
selfMute: false,
});
state.queue = [];
state.onEnd = async function () {
if (state.queue.length > 0) {
const next = state.queue.splice(0, 1)[0];
await enqueue({
guild_id,
voice_id,
text_id,
url: next.url,
type: next.type,
addedBy: next.addedBy,
});
} else {
await state.connection.disconnect();
if (!state.__leave) {
await hf.bot.guilds.get(guild_id).channels.get(text_id).createMessage({
content: ":musical_note: Queue is empty, leaving voice channel.",
});
await hf.bot.leaveVoiceChannel(voice_id);
}
state.connection.off("end", state.onEnd);
voiceStorage.delete(guild_id);
}
};
state.connection.on("end", state.onEnd);
voiceStorage.set(guild_id, state);
return state;
}
async function enqueue({
guild_id,
voice_id,
text_id,
url,
type,
addedBy,
suppress = false,
queueNext = false,
}) {
if (!url) return;
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 playdl.video_info(url);
} catch (err) {
await textChannel.createMessage({
content: `:warning: Failed to get metadata: \`\`\`\n${err}\n\`\`\``,
});
}
title = parseHtmlEntities(info?.video_details?.title);
length = info?.video_details?.durationInSec * 1000;
thumbnail = info?.video_details?.thumbnails?.[
info.video_details.thumbnails.length - 1
].url
.replace("vi_webp", "vi")
.replace(".webp", ".jpg");
let formats = info?.format;
if (formats) {
formats = formats.filter(
(obj) => obj.audioQuality != null && obj.container == "webm"
);
formats.sort((a, b) => b.bitrate - a.bitrate);
media = formats[0].url;
}
} else if (type == "sc") {
if (url?.startsWith("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({
content: `:warning: Failed to get metadata: \`\`\`\n${err}\n\`\`\``,
});
}
stream = !info.duration;
if (info.tags) {
title = `${
info.tags.artist ??
info.tags.ARTIST ??
info.tags.album_artist ??
info.tags.ALBUM_ARTIST ??
"<unknown artist>"
} - ${info.tags.title ?? info.tags.TITLE ?? "<no title>"}`;
}
length = info.duration ? Math.floor(info.duration) * 1000 : 0;
media = url;
}
if (connection.connection.playing) {
const queueItem = {
url,
type,
title,
length,
addedBy,
stream,
id: Math.random().toString(16).substring(2),
};
if (queueNext === true) {
connection.queue.splice(0, 0, queueItem);
} else {
connection.queue.push(queueItem);
}
if (suppress === false) {
textChannel.createMessage({
embeds: [
{
title: `<:ms_tick:503341995348066313> Added to queue ${
queueNext === true ? "(next up)" : ""
}`,
color: 0x00cc00,
fields: [
{
name: "Title",
value: (title && title != url
? `[${title}](${url})`
: url
).substring(0, 1024),
inline: true,
},
{
name: "Length",
value: stream
? "<continuous>"
: length
? formatTime(length)
: "<unknown>",
inline: true,
},
{
name: "Added by",
value: `<@${addedBy}>`,
inline: true,
},
],
thumbnail: {
url: thumbnail,
},
},
],
});
}
} else {
if (!media) {
textChannel.createMessage({
content: `:warning: No usable media was found for \`${url}\`. May possibly be due to region restrictions or paywalls.`,
});
return;
}
await connection.connection.play(media, {
inlineVolume: true,
voiceDataTimeout: -1,
});
textChannel.createMessage({
embeds: [
{
title: `:musical_note: Now Playing`,
color: 0x0088cc,
fields: [
{
name: "Title",
value: (title ? `[${title}](${url})` : url).substring(0, 1024),
inline: true,
},
{
name: "Length",
value: stream
? "<continuous>"
: length
? formatTime(length)
: "<unknown>",
inline: true,
},
{
name: "Added by",
value: `<@${addedBy}>`,
inline: true,
},
],
thumbnail: {
url: thumbnail,
},
},
],
});
connection.nowplaying = {
title,
addedBy,
thumbnail,
length,
start: Date.now(),
stream,
url,
};
}
}
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: `${parseHtmlEntities(item.snippet.title).substring(0, 99)}${
parseHtmlEntities(item.snippet.title).length > 99 ? "…" : ""
}`,
description: `from ${parseHtmlEntities(item.snippet.channelTitle).substring(0, 95)}${
parseHtmlEntities(item.snippet.channelTitle).length > 95 ? "…" : ""
}`,
}));
try {
return await selectionMessage(msg, "Search results:", selection);
} catch (out) {
return out;
}
}
const NOWPLAYING_BAR_LENGTH = 30;
const command = new Command("music");
command.addAlias("m");
command.category = "misc";
command.helpText = "Music";
command.usage = "help";
command.callback = async function (
msg,
line,
args,
{shuffle = false, limit = -1, offset = 0, next = false}
) {
if (!msg.guildID) return "This command can only be used in guilds.";
const subcommand = args.shift();
let argStr = args.join(" ");
switch (subcommand) {
case "play":
case "p":
if (msg.member?.voiceState?.channelID) {
if (voiceStorage.has(msg.guildID)) {
const connection = voiceStorage.get(msg.guildID);
if (connection.voice_id != msg.member.voiceState.channelID) {
return "You are in a different voice channel than the bot.";
}
}
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";
} else if (msg.attachments.length > 0) {
const entries = msg.attachments.filter((attachment) =>
REGEX_FILE.test(attachment.url)
);
if (entries.length > 0) {
type = "file";
argStr = entries[0].url;
}
}
if (type != null) {
if (playlist) {
const statusMessage = await msg.channel.createMessage({
embeds: [
{
title:
"<a:loading:493087964918972426> Processing playlist...",
description: `Fetching tracks...`,
color: 0xcc0088,
},
],
});
const playlist = await processPlaylist(
argStr,
type,
shuffle,
limit,
offset
);
await statusMessage.edit({
embeds: [
{
title:
"<a:loading:493087964918972426> Processing playlist...",
description: `${playlist.length} tracks`,
color: 0xcc0088,
},
],
});
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({
guild_id: msg.guildID,
voice_id: msg.member.voiceState.channelID,
text_id: msg.channel.id,
url,
type,
addedBy: msg.author.id,
suppress: true,
});
}
await statusMessage.edit({
embeds: [
{
title: "<:ms_tick:503341995348066313> Done processing",
description: `${playlist.length} tracks`,
color: 0xcc0088,
},
],
});
} else {
await enqueue({
guild_id: msg.guildID,
voice_id: msg.member.voiceState.channelID,
text_id: msg.channel.id,
url: argStr,
type,
addedBy: msg.author.id,
queueNext: next,
});
}
} else {
if (argStr.match(/^https?:\/\//)) {
const contentType = await fetch(argStr).then((res) =>
res.headers.get("Content-Type")
);
if (
contentType.startsWith("audio/") ||
contentType.startsWith("video/")
) {
await enqueue({
guild_id: msg.guildID,
voice_id: msg.member.voiceState.channelID,
text_id: msg.channel.id,
url: argStr,
type: "file",
addedBy: msg.author.id,
queueNext: next,
});
} else {
return "Unsupported content type.";
}
} else {
const url = await youtubeSearch(msg, argStr);
if (url?.startsWith("https://youtu.be/")) {
await enqueue({
guild_id: msg.guildID,
voice_id: msg.member.voiceState.channelID,
text_id: msg.channel.id,
url,
type: "yt",
addedBy: msg.author.id,
queueNext: next,
});
} 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.voice_id != msg.member.voiceState.channelID) {
return "You are in a different voice channel than the bot.";
}
}
// TODO: skip lock checks
await connection.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.voice_id != msg.member.voiceState.channelID) {
return "You are in a different voice channel than the bot.";
}
}
// TODO: skip lock checks
connection.queue = [];
connection.__leave = true;
await connection.connection.stopPlaying();
connection.onEnd();
await hf.bot.leaveVoiceChannel(msg.member.voiceState.channelID);
return {reaction: "\uD83D\uDC4B"};
} else {
return "You are not in a voice channel.";
}
case "np": {
if (!voiceStorage.has(msg.guildID))
return "The bot is not in a voice channel.";
const connection = voiceStorage.get(msg.guildID);
const nowPlaying = connection.nowplaying;
if (!nowPlaying || !connection.connection.playing)
return "Nothing is currently playing.";
const position = Date.now() - nowPlaying.start;
const timeEnd =
nowPlaying.length == 0 ? "\u221e" : formatTime(nowPlaying.length);
const timePos = formatTime(position);
const progress =
nowPlaying.length == 0 ? 1 : position / nowPlaying.length;
const barLength = Math.round(progress * NOWPLAYING_BAR_LENGTH);
const bar = `\`[${"=".repeat(barLength)}${" ".repeat(
NOWPLAYING_BAR_LENGTH - barLength
)}]\``;
const time = `\`${timePos}${" ".repeat(
NOWPLAYING_BAR_LENGTH + 2 - timePos.length - timeEnd.length
)}${timeEnd}\``;
return {
embed: {
title: ":musical_note: Now Playing",
color: 0x0088cc,
fields: [
{
name: "Title",
value: nowPlaying.title
? `[${nowPlaying.title}](${nowPlaying.url})`
: nowPlaying.url,
inline: true,
},
{
name: "Added by",
value: `<@${nowPlaying.addedBy}>`,
inline: true,
},
{
name: bar,
value: time,
inline: false,
},
],
},
};
}
case "queue":
case "q": {
if (!voiceStorage.has(msg.guildID))
return "The bot is not in a voice channel";
const connection = voiceStorage.get(msg.guildID);
const queue = connection.queue;
if (queue.length === 0) return "Nothing else is currently queued";
const nowPlaying = connection.nowplaying;
const now = Date.now();
let nextTrack = now + (nowPlaying.length - (now - nowPlaying.start));
const fields = [];
for (const index in queue.slice(0, 9)) {
const item = queue[index];
fields.push({
name: item.title ?? item.url,
value: `${item.title ? `[Link](${item.url}) - ` : ""}${formatTime(
item.length
)}\nAdded by: <@${item.addedBy}>\n<t:${Math.floor(
nextTrack / 1000
)}:R>`,
inline: true,
});
nextTrack += item.length;
}
let totalLength = 0;
for (const item of queue) {
totalLength += item.length;
}
return {
embed: {
title: ":inbox_tray: Currently Queued",
color: 0x0088cc,
fields,
footer: {
text:
(queue.length > 9 ? `Showing 9/${queue.length} items | ` : "") +
`Total length: ${formatTime(totalLength)}`,
},
},
};
}
case "remove":
case "qr":
if (msg.member?.voiceState?.channelID) {
const connection = voiceStorage.get(msg.guildID);
if (voiceStorage.has(msg.guildID)) {
if (connection.voice_id != msg.member.voiceState.channelID) {
return "You are in a different voice channel than the bot.";
}
}
let queue = connection.queue;
if (queue.length === 0) return "Nothing else is currently queued";
const hasManageMessages = msg.member.permissions.has("manageMessages");
if (!hasManageMessages)
queue = queue.filter((item) => item.addedBy == msg.member.id);
if (queue.length === 0) return "You currently have nothing queued";
const toRemove = await selectionMessage(
msg,
"Choose items to remove",
queue.slice(0, 25).map((item) => {
const user = hf.bot.users.get(item.addedBy);
return {
key: item.id,
display: (item.title ?? item.url).substr(0, 100),
description: hasManageMessages
? `Added by: ${user.username}#${user.discriminator}`
: "",
};
}),
30000,
Math.min(queue.length, 25)
);
if (Array.isArray(toRemove)) {
connection.queue = connection.queue.filter(
(item) => !toRemove.includes(item.id)
);
return `Removed ${toRemove.length} item(s).`;
} else {
return toRemove;
}
} else {
return "You are not in a voice channel";
}
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);