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

2
.gitignore vendored
View file

@ -2,5 +2,5 @@
deezer_cache
orpheus_links.txt
discord_send.txt
state.json
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 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);

View file

@ -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,20 +13,24 @@ function download(url, filename, referer = url) {
let file = null; // will open later
function doReq(link, ref) {
C.info(`Requesting: ${link}`);
https.get(link, {
https
.get(
link,
{
headers: {
"User-Agent": "Mozilla/5.0",
"Referer": ref
}
}, res => {
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;
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);
}
@ -46,18 +48,25 @@ function download(url, filename, referer = url) {
let downloaded = 0;
const start = Date.now();
res.on("data", chunk => {
res.on("data", (chunk) => {
downloaded += chunk.length;
if (total) {
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 filled = Math.round(percent / 100 * barSize);
const bar = "[" + "#".repeat(filled) + "-".repeat(barSize - filled) + "]";
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`);
process.stdout.write(
`\rDownloaded ${Math.round(downloaded / 1024)}KB`,
);
}
});
@ -68,16 +77,14 @@ function download(url, filename, referer = url) {
C.ok("Download complete!");
resolve();
});
}).on("error", err => reject(err));
},
)
.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);

View file

@ -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,9 +85,6 @@ 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;
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",
},
],
});