format everything, make now playying actually good

This commit is contained in:
Soph :3 2026-01-13 12:55:47 +02:00
parent 4a3b3aabcf
commit fca2614603
5 changed files with 313 additions and 173 deletions

View file

@ -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" DISCORD_TOKEN="no.no"
LASTFM_API_KEY=no LASTFM_API_KEY=no

2
.gitignore vendored
View file

@ -2,5 +2,5 @@
deezer_cache deezer_cache
orpheus_links.txt orpheus_links.txt
discord_send.txt discord_send.txt
state.json
node_modules node_modules

View file

@ -1,112 +1,164 @@
import { Client, GatewayIntentBits } from "discord.js";
import crypto from "crypto";
import { existsSync, readFileSync, writeFileSync } from "fs"; import { existsSync, readFileSync, writeFileSync } from "fs";
import crypto from "crypto"
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;
lastSignature = state.lastSignature ?? null;
} catch {}
}
function saveState() {
writeFileSync(
STATE_FILE,
JSON.stringify({ messageId, lastSignature }, null, 2),
);
}
function navidromeAuth() {
const salt = crypto.randomBytes(6).toString("hex"); const salt = crypto.randomBytes(6).toString("hex");
const token = crypto const token = crypto
.createHash("md5") .createHash("md5")
.update(process.env.NAVIDROME_PASS + salt) .update(process.env.NAVIDROME_PASS + salt)
.digest("hex"); .digest("hex");
return { salt, token };
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 STATE_FILE = "./state.json";
let messageId = null;
if (existsSync(STATE_FILE)) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
messageId = state.messageId ?? null;
} catch {
// will be made later
}
} }
function saveState() { function nowPlayingUrl() {
writeFileSync(STATE_FILE, JSON.stringify({ messageId }, null, 2)); 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) { function colorFromString(str) {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (const c of str) hash = (hash << 5) - hash + c.charCodeAt(0);
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 0xffffff; return Math.abs(hash) % 0xffffff;
} }
function signature(entries) {
return entries
.map((e) => `${e.username}:${e.id}:${e.minutesAgo ?? 0}`)
.join("|");
}
async function getNowPlaying() { 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"); if (!res.ok) throw new Error("Failed to fetch now playing");
const json = await res.json(); const json = await res.json();
return json["subsonic-response"].nowPlaying?.entry ?? []; return json["subsonic-response"].nowPlaying?.entry ?? [];
} }
async function buildPayload(entries) { async function buildMessage(entries) {
if (entries.length === 0) { if (!entries.length) {
return { embeds: [{ title: "Nothing playing", description: "No active listeners", color: 0x808080 }], files: [] }; return {
embeds: [
{
title: "Nothing playing",
description: "No active listeners",
color: 0x808080,
},
],
files: [],
};
} }
const embeds = []; const embeds = [];
const files = []; const files = [];
for (const e of entries) { for (let i = 0; i < entries.length; i++) {
const filename = `cover_${e.id}.jpg`; const e = entries[i];
const imgRes = await fetch(COVER_FETCH_URL(e.coverArt)); const filename = `cover_${i}.jpg`;
const imgRes = await fetch(coverFetchUrl(e.coverArt));
if (imgRes.ok) { if (imgRes.ok) {
const buf = await imgRes.arrayBuffer(); const buf = Buffer.from(await imgRes.arrayBuffer());
files.push({ name: filename, data: new Blob([buf]) }); files.push({ attachment: buf, name: filename });
} }
embeds.push({ embeds.push({
title: e.title, title: e.title,
description: `**${e.artist}** — *${e.album}*`, description: `**${e.artist}** — *${e.album}*`,
color: colorFromString(e.username), color: colorFromString(e.username),
thumbnail: { url: `attachment://${filename}` }, thumbnail: files.length ? { url: `attachment://${filename}` } : undefined,
fields: [ fields: [
{ name: "User", value: e.username, inline: true }, { name: "User", value: e.username, inline: true },
{ name: "Player", value: e.playerName, 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 }; return { embeds, files };
} }
async function sendOrResend(payload) { async function updateMessage() {
// delete previous message if exists const entries = await getNowPlaying();
if (messageId) { const sig = signature(entries);
await fetch(`${WEBHOOK_URL}/messages/${messageId}`, { method: "DELETE" }).catch(() => {});
messageId = null; 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(); lastSignature = sig;
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;
saveState(); saveState();
} }
async function tick() { client.once("ready", async () => {
try { console.log(`Logged in as ${client.user.tag}`);
const entries = await getNowPlaying(); await updateMessage();
const payload = await buildPayload(entries); setInterval(() => {
await sendOrResend(payload); updateMessage().catch((err) =>
} catch (err) { console.error("Update failed:", err.message),
console.error("Update failed:", err.message); );
} }, REFRESH_MS);
} });
await tick(); client.login(process.env.DISCORD_BOT_TOKEN);
setInterval(tick, REFRESH_MS);

View file

@ -1,12 +1,10 @@
import https from "https"; import https from "https";
import { createWriteStream } from "fs"; import { createWriteStream } from "fs";
// ======== COLOR UTILS ========== info: (msg) => console.log("\x1b[36m[i]\x1b[0m " + msg),
const C = { ok: (msg) => console.log("\x1b[32m[✓]\x1b[0m " + msg),
info: msg => console.log("\x1b[36m[i]\x1b[0m " + msg), warn: (msg) => console.log("\x1b[33m[!]\x1b[0m " + msg),
ok: msg => console.log("\x1b[32m[✓]\x1b[0m " + msg), err: (msg) => console.log("\x1b[31m[✗]\x1b[0m " + msg),
warn: msg => console.log("\x1b[33m[!]\x1b[0m " + msg),
err: msg => console.log("\x1b[31m[✗]\x1b[0m " + msg),
}; };
// ======== DOWNLOAD WITH PROGRESS ========== // ======== DOWNLOAD WITH PROGRESS ==========
@ -15,20 +13,24 @@ function download(url, filename, referer = url) {
let file = null; // will open later let file = null; // will open later
function doReq(link, ref) { function doReq(link, ref) {
C.info(`Requesting: ${link}`); https
.get(
https.get(link, { link,
{
headers: { headers: {
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
"Referer": ref Referer: ref,
} },
}, res => { },
(res) => {
// --- Redirect handler --- // --- Redirect handler ---
if ([301, 302, 303, 307, 308].includes(res.statusCode)) { if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
const loc = res.headers.location; const loc = res.headers.location;
if (!loc) return reject(new Error("Redirect without Location header")); if (!loc)
const nextURL = loc.startsWith("http") ? loc : new URL(loc, link).href; 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}`); C.warn(`↪ Redirect (${res.statusCode}): ${link}${nextURL}`);
return doReq(nextURL, link); return doReq(nextURL, link);
} }
@ -46,18 +48,25 @@ function download(url, filename, referer = url) {
let downloaded = 0; let downloaded = 0;
const start = Date.now(); const start = Date.now();
res.on("data", chunk => { res.on("data", (chunk) => {
downloaded += chunk.length; downloaded += chunk.length;
if (total) { if (total) {
const percent = ((downloaded / total) * 100).toFixed(1); const percent = ((downloaded / total) * 100).toFixed(1);
const speed = (downloaded / 1024 / ((Date.now() - start) / 1000)).toFixed(1); const speed = (
downloaded /
1024 /
((Date.now() - start) / 1000)
).toFixed(1);
const barSize = 30; const barSize = 30;
const filled = Math.round(percent / 100 * barSize); const filled = Math.round((percent / 100) * barSize);
const bar = "[" + "#".repeat(filled) + "-".repeat(barSize - filled) + "]"; const bar =
"[" + "#".repeat(filled) + "-".repeat(barSize - filled) + "]";
process.stdout.write(`\r${bar} ${percent}% ${speed}KB/s`); process.stdout.write(`\r${bar} ${percent}% ${speed}KB/s`);
} else { } else {
process.stdout.write(`\rDownloaded ${Math.round(downloaded / 1024)}KB`); process.stdout.write(
`\rDownloaded ${Math.round(downloaded / 1024)}KB`,
);
} }
}); });
@ -68,15 +77,13 @@ function download(url, filename, referer = url) {
C.ok("Download complete!"); C.ok("Download complete!");
resolve(); resolve();
}); });
},
}).on("error", err => reject(err)); )
.on("error", (err) => reject(err));
} }
doReq(url, referer); doReq(url, referer);
}); });
}
// ======== MAIN FUNCTION ========== // ======== MAIN FUNCTION ==========
(async () => { (async () => {
const target = process.argv[2]; const target = process.argv[2];
@ -88,9 +95,9 @@ function download(url, filename, referer = url) {
C.info(`Fetching webpage: ${target}`); C.info(`Fetching webpage: ${target}`);
let html; html = await fetch(target, {
try { headers: { "User-Agent": "Mozilla/5.0" },
html = await fetch(target, { headers: { "User-Agent": "Mozilla/5.0" } }).then(r => r.text()); }).then((r) => r.text());
} catch (e) { } catch (e) {
C.err("Failed to fetch the page"); C.err("Failed to fetch the page");
console.error(e); console.error(e);
@ -105,16 +112,12 @@ function download(url, filename, referer = url) {
C.ok("Found data[] block"); C.ok("Found data[] block");
// Safer eval (only our captured data) // Safer eval (only our captured data)
let arr;
try {
arr = eval("[" + match[1] + "]"); arr = eval("[" + match[1] + "]");
} catch (e) { } catch (e) {
C.err("❌ Failed to parse data[] as JavaScript"); C.err("❌ Failed to parse data[] as JavaScript");
console.error(e); console.error(e);
process.exit(1); 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) { if (!item) {
C.err("❌ Could not find downloadable file info in data[]"); C.err("❌ Could not find downloadable file info in data[]");
process.exit(1); process.exit(1);

View file

@ -12,25 +12,23 @@ const client = new Client({
}); });
const command = new SlashCommandBuilder() const command = new SlashCommandBuilder()
.setName("request") .addStringOption((opt) =>
.setDescription("Request a new artist or submit a SendGB link")
.addStringOption(opt =>
opt opt
.setName("artist") .setName("artist")
.setDescription("Artist name (Last.fm)") .setDescription("Artist name (Last.fm)")
.setRequired(false) .setRequired(false),
) )
.addStringOption(opt => .addStringOption((opt) =>
opt opt
.setName("url") .setName("url")
.setDescription("SendGB URL (https://sendgb.com/...)") .setDescription("SendGB URL (https://sendgb.com/...)")
.setRequired(false) .setRequired(false),
) )
.addStringOption(opt => .addStringOption((opt) =>
opt opt
.setName("description") .setName("description")
.setDescription("Description for the SendGB link") .setDescription("Description for the SendGB link")
.setRequired(false) .setRequired(false),
); );
function isValidSendGbUrl(url) { function isValidSendGbUrl(url) {
@ -43,14 +41,11 @@ function isValidDeezerUrl(url) {
const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN); const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN);
await rest.put( await rest.put(
Routes.applicationGuildCommands( process.env.GUILD_ID,
(await rest.get(Routes.oauth2CurrentApplication())).id,
process.env.GUILD_ID
), ),
{ body: [command.toJSON()] } { body: [command.toJSON()] },
); );
async function getDeezerAlbum(url) { async function getDeezerAlbum(url) {
const match = url.match(/album\/(\d+)/); const match = url.match(/album\/(\d+)/);
if (!match) return null; if (!match) return null;
@ -65,9 +60,8 @@ async function getDeezerAlbum(url) {
title: data.title, title: data.title,
artist: data.artist.name, artist: data.artist.name,
link: data.link, link: data.link,
cover: data.cover_medium, tracks:
releaseDate: data.release_date, data.tracks?.data?.map((t) => t.title).join(", ") || "No tracks info",
tracks: data.tracks?.data?.map(t => t.title).join(", ") || "No tracks info",
}; };
} }
@ -91,9 +85,6 @@ async function getLastFmArtist(artist) {
console.error("Last.fm response was not JSON:"); console.error("Last.fm response was not JSON:");
console.error(text.slice(0, 500)); console.error(text.slice(0, 500));
return null; return null;
}
if (!data.artist) return null; if (!data.artist) return null;
return data.artist; return data.artist;
} }
@ -112,9 +103,7 @@ async function navidromeHasArtist(artist) {
t: token, t: token,
s: salt, s: salt,
v: "1.16.1", v: "1.16.1",
c: "discord-bot", f: "json",
query: artist,
f: "json"
}); });
const res = await fetch(url); const res = await fetch(url);
@ -127,18 +116,12 @@ async function navidromeHasArtist(artist) {
console.error("Navidrome response was not JSON:"); console.error("Navidrome response was not JSON:");
console.error(text.slice(0, 500)); console.error(text.slice(0, 500));
return false; return false;
const artists = json["subsonic-response"]?.searchResult3?.artist ?? [];
return artists.some((a) => a.name.toLowerCase() === artist.toLowerCase());
} }
const artists = client.on("interactionCreate", async (interaction) => {
json["subsonic-response"]?.searchResult3?.artist ?? [];
return artists.some(
a => a.name.toLowerCase() === artist.toLowerCase()
);
}
client.on("interactionCreate", async interaction => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
if (interaction.commandName !== "request") return; if (interaction.commandName !== "request") return;
@ -175,9 +158,10 @@ client.on("interactionCreate", async interaction => {
embeds: [ embeds: [
{ {
title: "📦 External Upload", title: "📦 External Upload",
description, {
fields: [ name: "Download",
{ name: "Download", value: url+"#"+encodeURIComponent(description) }, value: url + "#" + encodeURIComponent(description),
},
{ name: "Requested by", value: `<@${interaction.user.id}>` }, { 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({ return interaction.editReply({
@ -225,9 +209,9 @@ client.on("interactionCreate", async interaction => {
}); });
} }
const artist = await getLastFmArtist(artistName); return interaction.editReply({
if (!artist) { content: "❌ Artist not found on Last.fm.",
return interaction.editReply({ content: "❌ Artist not found on Last.fm." }); });
} }
await channel.send({ await channel.send({
@ -235,17 +219,22 @@ client.on("interactionCreate", async interaction => {
{ {
title: artist.name, title: artist.name,
url: artist.url, url: artist.url,
description: artist.bio?.summary?.replace(/<[^>]*>/g, "") ?? "No description available.", description:
artist.bio?.summary?.replace(/<[^>]*>/g, "") ??
"No description available.",
thumbnail: { thumbnail: {
url: artist.image?.find(i => i.size === "extralarge")?.["#text"] ?? null, url:
artist.image?.find((i) => i.size === "extralarge")?.["#text"] ??
null,
}, },
fields: [ fields: [
{ name: "Listeners", value: artist.stats.listeners, inline: true }, { name: "Listeners", value: artist.stats.listeners, inline: true },
{ name: "Playcount", value: artist.stats.playcount, 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",
}, },
], ],
}); });