From fca26146030fe6cc711226c80f959370f03c35df Mon Sep 17 00:00:00 2001 From: fucksophie Date: Tue, 13 Jan 2026 12:55:47 +0200 Subject: [PATCH] format everything, make now playying actually good --- .env.example | 98 +++++++++++++++++++++++++- .gitignore | 2 +- src/now-playing.js | 168 +++++++++++++++++++++++++++++---------------- src/pillow.js | 137 ++++++++++++++++++------------------ src/request-bot.js | 81 ++++++++++------------ 5 files changed, 313 insertions(+), 173 deletions(-) diff --git a/.env.example b/.env.example index 8b64e1a..cea5ba5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,100 @@ -WEBHOOK_URL=https://discord.com/api/webhooks/no/no +import { Client, GatewayIntentBits } from "discord.js"; +import crypto from "crypto"; +import { existsSync, readFileSync, writeFileSync } from "fs"; + +/* ================= CONFIG ================= */ +const REFRESH_MS = Number(process.env.REFRESH_MS ?? 15_000); +const STATE_FILE = "./state.json"; + +/* ================= DISCORD ================= */ +const client = new Client({ + intents: [GatewayIntentBits.Guilds], +}); + +/* ================= STATE ================= */ +let messageId = null; +let lastSignature = null; + +if (existsSync(STATE_FILE)) { + try { + const state = JSON.parse(readFileSync(STATE_FILE, "utf8")); + messageId = state.messageId ?? null; + lastSignature = state.lastSignature ?? null; + } catch {} +} + +function saveState() { + writeFileSync( + STATE_FILE, + JSON.stringify({ messageId, lastSignature }, null, 2) + ); +} + +/* ================= NAVIDROME AUTH ================= */ +function navidromeAuth() { + const salt = crypto.randomBytes(6).toString("hex"); + const token = crypto + .createHash("md5") + .update(process.env.NAVIDROME_PASS + salt) + .digest("hex"); + + return { salt, token }; +} + +function nowPlayingUrl() { + const { salt, token } = navidromeAuth(); + return `${process.env.NAVIDROME_URL}/rest/getNowPlaying` + + `?u=${process.env.NAVIDROME_USER}` + + `&t=${token}&s=${salt}&f=json&v=1.16.1&c=discord-bot`; +} + +function coverFetchUrl(id) { + const { salt, token } = navidromeAuth(); + return `${process.env.NAVIDROME_URL}/rest/getCoverArt` + + `?u=${process.env.NAVIDROME_USER}` + + `&t=${token}&s=${salt}&v=1.16.1&c=discord-bot` + + `&id=${id}&size=96`; +} + +/* ================= UTIL ================= */ +function colorFromString(str) { + let hash = 0; + for (const c of str) hash = (hash << 5) - hash + c.charCodeAt(0); + return Math.abs(hash) % 0xffffff; +} + +function signature(entries) { + return entries + .map(e => `${e.username}:${e.id}:${e.minutesAgo ?? 0}`) + .join("|"); +} + +/* ================= NAVIDROME ================= */ +async function getNowPlaying() { + const res = await fetch(nowPlayingUrl()); + if (!res.ok) throw new Error("Failed to fetch now playing"); + + const json = await res.json(); + return json["subsonic-response"].nowPlaying?.entry ?? []; +} + +/* ================= MESSAGE BUILD ================= */ +async function buildMessage(entries) { + if (!entries.length) { + return { + embeds: [{ + title: "Nothing playing", + description: "No active listeners", + color: 0x808080, + }], + files: [], + }; + } + + const embeds = []; + const files = []; + + for (let i = 0; i < e DISCORD_TOKEN="no.no" LASTFM_API_KEY=no diff --git a/.gitignore b/.gitignore index 5e2a891..501a0fc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ deezer_cache orpheus_links.txt discord_send.txt - +state.json node_modules diff --git a/src/now-playing.js b/src/now-playing.js index 9128f2c..b1e94e2 100644 --- a/src/now-playing.js +++ b/src/now-playing.js @@ -1,112 +1,164 @@ +import { Client, GatewayIntentBits } from "discord.js"; +import crypto from "crypto"; import { existsSync, readFileSync, writeFileSync } from "fs"; -import crypto from "crypto" -const salt = crypto.randomBytes(6).toString("hex"); -const token = crypto - .createHash("md5") - .update(process.env.NAVIDROME_PASS + salt) - .digest("hex"); - - -const NOW_PLAYING_URL = `${process.env.NAVIDROME_URL}/rest/getNowPlaying?u=${process.env.NAVIDROME_USER}&t=${token}&s=${salt}&f=json&v=0.0.1&c=now-playing`; -const COVER_FETCH_URL = (id) => - `${process.env.NAVIDROME_URL}/rest/getCoverArt?u=${process.env.NAVIDROME_USER}&t=${token}&s=${salt}&f=json&v=0.0.1&c=now-playing&id=${id}&size=80`; - -const WEBHOOK_URL = process.env.WEBHOOK_URL; -const REFRESH_MS = 15_000; +const REFRESH_MS = Number(process.env.REFRESH_MS ?? 15_000); const STATE_FILE = "./state.json"; +const client = new Client({ + intents: [GatewayIntentBits.Guilds], +}); + let messageId = null; +let lastSignature = null; if (existsSync(STATE_FILE)) { try { const state = JSON.parse(readFileSync(STATE_FILE, "utf8")); messageId = state.messageId ?? null; - } catch { - // will be made later - } + lastSignature = state.lastSignature ?? null; + } catch {} } function saveState() { - writeFileSync(STATE_FILE, JSON.stringify({ messageId }, null, 2)); + writeFileSync( + STATE_FILE, + JSON.stringify({ messageId, lastSignature }, null, 2), + ); +} + +function navidromeAuth() { + const salt = crypto.randomBytes(6).toString("hex"); + const token = crypto + .createHash("md5") + .update(process.env.NAVIDROME_PASS + salt) + .digest("hex"); + + return { salt, token }; +} + +function nowPlayingUrl() { + const { salt, token } = navidromeAuth(); + return ( + `${process.env.NAVIDROME_URL}/rest/getNowPlaying` + + `?u=${process.env.NAVIDROME_USER}` + + `&t=${token}&s=${salt}&f=json&v=1.16.1&c=discord-bot` + ); +} + +function coverFetchUrl(id) { + const { salt, token } = navidromeAuth(); + return ( + `${process.env.NAVIDROME_URL}/rest/getCoverArt` + + `?u=${process.env.NAVIDROME_USER}` + + `&t=${token}&s=${salt}&v=1.16.1&c=discord-bot` + + `&id=${id}&size=96` + ); } function colorFromString(str) { let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = (hash << 5) - hash + str.charCodeAt(i); - hash |= 0; - } + for (const c of str) hash = (hash << 5) - hash + c.charCodeAt(0); return Math.abs(hash) % 0xffffff; } +function signature(entries) { + return entries + .map((e) => `${e.username}:${e.id}:${e.minutesAgo ?? 0}`) + .join("|"); +} + async function getNowPlaying() { - const res = await fetch(NOW_PLAYING_URL); + const res = await fetch(nowPlayingUrl()); if (!res.ok) throw new Error("Failed to fetch now playing"); + const json = await res.json(); return json["subsonic-response"].nowPlaying?.entry ?? []; } -async function buildPayload(entries) { - if (entries.length === 0) { - return { embeds: [{ title: "Nothing playing", description: "No active listeners", color: 0x808080 }], files: [] }; +async function buildMessage(entries) { + if (!entries.length) { + return { + embeds: [ + { + title: "Nothing playing", + description: "No active listeners", + color: 0x808080, + }, + ], + files: [], + }; } const embeds = []; const files = []; - for (const e of entries) { - const filename = `cover_${e.id}.jpg`; - const imgRes = await fetch(COVER_FETCH_URL(e.coverArt)); + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const filename = `cover_${i}.jpg`; + + const imgRes = await fetch(coverFetchUrl(e.coverArt)); if (imgRes.ok) { - const buf = await imgRes.arrayBuffer(); - files.push({ name: filename, data: new Blob([buf]) }); + const buf = Buffer.from(await imgRes.arrayBuffer()); + files.push({ attachment: buf, name: filename }); } embeds.push({ title: e.title, description: `**${e.artist}** — *${e.album}*`, color: colorFromString(e.username), - thumbnail: { url: `attachment://${filename}` }, + thumbnail: files.length ? { url: `attachment://${filename}` } : undefined, fields: [ { name: "User", value: e.username, inline: true }, { name: "Player", value: e.playerName, inline: true }, - { name: "Duration", value: `${Math.floor(e.duration / 60)}:${String(e.duration % 60).padStart(2, "0")}`, inline: false }, + { + name: "Duration", + value: `${Math.floor(e.duration / 60)}:${String( + e.duration % 60, + ).padStart(2, "0")}`, + }, ], - footer: { text: "now playing!" }, + footer: { text: "now playing" }, }); } return { embeds, files }; } -async function sendOrResend(payload) { - // delete previous message if exists - if (messageId) { - await fetch(`${WEBHOOK_URL}/messages/${messageId}`, { method: "DELETE" }).catch(() => {}); - messageId = null; +async function updateMessage() { + const entries = await getNowPlaying(); + const sig = signature(entries); + + if (sig === lastSignature) return; + + const payload = await buildMessage(entries); + const channel = await client.channels.fetch(process.env.DISCORD_CHANNEL_ID); + + if (!messageId) { + const msg = await channel.send(payload); + messageId = msg.id; + } else { + try { + const msg = await channel.messages.fetch(messageId); + await msg.edit(payload); + } catch { + const msg = await channel.send(payload); + messageId = msg.id; + } } - const form = new FormData(); - payload.files.forEach((f, i) => form.append(`files[${i}]`, f.data, f.name)); - form.append("payload_json", JSON.stringify({ embeds: payload.embeds })); - - const res = await fetch(`${WEBHOOK_URL}?wait=true`, { method: "POST", body: form }); - if (!res.ok) throw new Error("Webhook send failed"); - const data = await res.json(); - messageId = data.id; + lastSignature = sig; saveState(); } -async function tick() { - try { - const entries = await getNowPlaying(); - const payload = await buildPayload(entries); - await sendOrResend(payload); - } catch (err) { - console.error("Update failed:", err.message); - } -} +client.once("ready", async () => { + console.log(`Logged in as ${client.user.tag}`); + await updateMessage(); + setInterval(() => { + updateMessage().catch((err) => + console.error("Update failed:", err.message), + ); + }, REFRESH_MS); +}); -await tick(); -setInterval(tick, REFRESH_MS); +client.login(process.env.DISCORD_BOT_TOKEN); diff --git a/src/pillow.js b/src/pillow.js index b7858d1..c5aec82 100644 --- a/src/pillow.js +++ b/src/pillow.js @@ -1,12 +1,10 @@ import https from "https"; import { createWriteStream } from "fs"; -// ======== COLOR UTILS ========== -const C = { - info: msg => console.log("\x1b[36m[i]\x1b[0m " + msg), - ok: msg => console.log("\x1b[32m[✓]\x1b[0m " + msg), - warn: msg => console.log("\x1b[33m[!]\x1b[0m " + msg), - err: msg => console.log("\x1b[31m[✗]\x1b[0m " + msg), +info: (msg) => console.log("\x1b[36m[i]\x1b[0m " + msg), + ok: (msg) => console.log("\x1b[32m[✓]\x1b[0m " + msg), + warn: (msg) => console.log("\x1b[33m[!]\x1b[0m " + msg), + err: (msg) => console.log("\x1b[31m[✗]\x1b[0m " + msg), }; // ======== DOWNLOAD WITH PROGRESS ========== @@ -15,69 +13,78 @@ function download(url, filename, referer = url) { let file = null; // will open later function doReq(link, ref) { - C.info(`Requesting: ${link}`); + https + .get( + link, + { + headers: { + "User-Agent": "Mozilla/5.0", + Referer: ref, + }, + }, + (res) => { + // --- Redirect handler --- + if ([301, 302, 303, 307, 308].includes(res.statusCode)) { + const loc = res.headers.location; + if (!loc) + return reject(new Error("Redirect without Location header")); + const nextURL = loc.startsWith("http") + ? loc + : new URL(loc, link).href; + C.warn(`↪ Redirect (${res.statusCode}): ${link} → ${nextURL}`); + return doReq(nextURL, link); + } - https.get(link, { - headers: { - "User-Agent": "Mozilla/5.0", - "Referer": ref - } - }, res => { + // --- Error handler --- + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode}`)); + } - // --- Redirect handler --- - if ([301, 302, 303, 307, 308].includes(res.statusCode)) { - const loc = res.headers.location; - if (!loc) return reject(new Error("Redirect without Location header")); - const nextURL = loc.startsWith("http") ? loc : new URL(loc, link).href; - C.warn(`↪ Redirect (${res.statusCode}): ${link} → ${nextURL}`); - return doReq(nextURL, link); - } + // ======= OPEN FILE NOW (final target reached) ======= + if (!file) file = createWriteStream(filename); - // --- Error handler --- - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode}`)); - } + // ======= Progress ======= + const total = parseInt(res.headers["content-length"] || "0", 10); + let downloaded = 0; + const start = Date.now(); - // ======= OPEN FILE NOW (final target reached) ======= - if (!file) file = createWriteStream(filename); + res.on("data", (chunk) => { + downloaded += chunk.length; - // ======= Progress ======= - const total = parseInt(res.headers["content-length"] || "0", 10); - let downloaded = 0; - const start = Date.now(); + if (total) { + const percent = ((downloaded / total) * 100).toFixed(1); + const speed = ( + downloaded / + 1024 / + ((Date.now() - start) / 1000) + ).toFixed(1); + const barSize = 30; + const filled = Math.round((percent / 100) * barSize); + const bar = + "[" + "#".repeat(filled) + "-".repeat(barSize - filled) + "]"; + process.stdout.write(`\r${bar} ${percent}% ${speed}KB/s`); + } else { + process.stdout.write( + `\rDownloaded ${Math.round(downloaded / 1024)}KB`, + ); + } + }); - res.on("data", chunk => { - downloaded += chunk.length; + res.pipe(file); - if (total) { - const percent = ((downloaded / total) * 100).toFixed(1); - const speed = (downloaded / 1024 / ((Date.now() - start) / 1000)).toFixed(1); - const barSize = 30; - const filled = Math.round(percent / 100 * barSize); - const bar = "[" + "#".repeat(filled) + "-".repeat(barSize - filled) + "]"; - process.stdout.write(`\r${bar} ${percent}% ${speed}KB/s`); - } else { - process.stdout.write(`\rDownloaded ${Math.round(downloaded / 1024)}KB`); - } - }); - - res.pipe(file); - - file.on("finish", () => { - process.stdout.write("\n"); - C.ok("Download complete!"); - resolve(); - }); - - }).on("error", err => reject(err)); + file.on("finish", () => { + process.stdout.write("\n"); + C.ok("Download complete!"); + resolve(); + }); + }, + ) + .on("error", (err) => reject(err)); } doReq(url, referer); }); -} - - -// ======== MAIN FUNCTION ========== + // ======== MAIN FUNCTION ========== (async () => { const target = process.argv[2]; @@ -88,9 +95,9 @@ function download(url, filename, referer = url) { C.info(`Fetching webpage: ${target}`); - let html; - try { - html = await fetch(target, { headers: { "User-Agent": "Mozilla/5.0" } }).then(r => r.text()); + html = await fetch(target, { + headers: { "User-Agent": "Mozilla/5.0" }, + }).then((r) => r.text()); } catch (e) { C.err("Failed to fetch the page"); console.error(e); @@ -105,16 +112,12 @@ function download(url, filename, referer = url) { C.ok("Found data[] block"); // Safer eval (only our captured data) - let arr; - try { - arr = eval("["+match[1]+"]"); + arr = eval("[" + match[1] + "]"); } catch (e) { C.err("❌ Failed to parse data[] as JavaScript"); console.error(e); process.exit(1); - } - - const item = arr.find(x => x?.type === "data" && x?.data?.filename); + const item = arr.find((x) => x?.type === "data" && x?.data?.filename); if (!item) { C.err("❌ Could not find downloadable file info in data[]"); process.exit(1); diff --git a/src/request-bot.js b/src/request-bot.js index 5242f18..9ef9d89 100644 --- a/src/request-bot.js +++ b/src/request-bot.js @@ -12,25 +12,23 @@ const client = new Client({ }); const command = new SlashCommandBuilder() - .setName("request") - .setDescription("Request a new artist or submit a SendGB link") - .addStringOption(opt => + .addStringOption((opt) => opt .setName("artist") .setDescription("Artist name (Last.fm)") - .setRequired(false) + .setRequired(false), ) - .addStringOption(opt => + .addStringOption((opt) => opt .setName("url") .setDescription("SendGB URL (https://sendgb.com/...)") - .setRequired(false) + .setRequired(false), ) - .addStringOption(opt => + .addStringOption((opt) => opt .setName("description") .setDescription("Description for the SendGB link") - .setRequired(false) + .setRequired(false), ); function isValidSendGbUrl(url) { @@ -43,14 +41,11 @@ function isValidDeezerUrl(url) { const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN); await rest.put( - Routes.applicationGuildCommands( - (await rest.get(Routes.oauth2CurrentApplication())).id, - process.env.GUILD_ID + process.env.GUILD_ID, ), - { body: [command.toJSON()] } + { body: [command.toJSON()] }, ); - async function getDeezerAlbum(url) { const match = url.match(/album\/(\d+)/); if (!match) return null; @@ -65,9 +60,8 @@ async function getDeezerAlbum(url) { title: data.title, artist: data.artist.name, link: data.link, - cover: data.cover_medium, - releaseDate: data.release_date, - tracks: data.tracks?.data?.map(t => t.title).join(", ") || "No tracks info", + tracks: + data.tracks?.data?.map((t) => t.title).join(", ") || "No tracks info", }; } @@ -91,10 +85,7 @@ async function getLastFmArtist(artist) { console.error("Last.fm response was not JSON:"); console.error(text.slice(0, 500)); return null; - } - - - if (!data.artist) return null; + if (!data.artist) return null; return data.artist; } @@ -112,9 +103,7 @@ async function navidromeHasArtist(artist) { t: token, s: salt, v: "1.16.1", - c: "discord-bot", - query: artist, - f: "json" + f: "json", }); const res = await fetch(url); @@ -127,18 +116,12 @@ async function navidromeHasArtist(artist) { console.error("Navidrome response was not JSON:"); console.error(text.slice(0, 500)); return false; - } + const artists = json["subsonic-response"]?.searchResult3?.artist ?? []; - const artists = - json["subsonic-response"]?.searchResult3?.artist ?? []; - - return artists.some( - a => a.name.toLowerCase() === artist.toLowerCase() - ); + return artists.some((a) => a.name.toLowerCase() === artist.toLowerCase()); } - -client.on("interactionCreate", async interaction => { +client.on("interactionCreate", async (interaction) => { if (!interaction.isChatInputCommand()) return; if (interaction.commandName !== "request") return; @@ -175,9 +158,10 @@ client.on("interactionCreate", async interaction => { embeds: [ { title: "📦 External Upload", - description, - fields: [ - { name: "Download", value: url+"#"+encodeURIComponent(description) }, + { + name: "Download", + value: url + "#" + encodeURIComponent(description), + }, { name: "Requested by", value: `<@${interaction.user.id}>` }, ], }, @@ -208,9 +192,9 @@ client.on("interactionCreate", async interaction => { ], }, ], + return interaction.editReply({ + content: "✅ Deezer album info submitted.", }); - - return interaction.editReply({ content: "✅ Deezer album info submitted." }); } return interaction.editReply({ @@ -225,9 +209,9 @@ client.on("interactionCreate", async interaction => { }); } - const artist = await getLastFmArtist(artistName); - if (!artist) { - return interaction.editReply({ content: "❌ Artist not found on Last.fm." }); + return interaction.editReply({ + content: "❌ Artist not found on Last.fm.", + }); } await channel.send({ @@ -235,17 +219,22 @@ client.on("interactionCreate", async interaction => { { title: artist.name, url: artist.url, - description: artist.bio?.summary?.replace(/<[^>]*>/g, "") ?? "No description available.", + description: + artist.bio?.summary?.replace(/<[^>]*>/g, "") ?? + "No description available.", thumbnail: { - url: artist.image?.find(i => i.size === "extralarge")?.["#text"] ?? null, + url: + artist.image?.find((i) => i.size === "extralarge")?.["#text"] ?? + null, }, fields: [ { name: "Listeners", value: artist.stats.listeners, inline: true }, { name: "Playcount", value: artist.stats.playcount, inline: true }, - { name: "Tags", value: artist.tags?.tag?.map(t => t.name).join(", ") || "None" }, - { name: "Requested by", value: `<@${interaction.user.id}>` }, - ], - }, + { + name: "Tags", + value: artist.tags?.tag?.map((t) => t.name).join(", ") || "None", + }, + ], });