import { readFile } from "node:fs/promises"; import { basename } from "node:path"; import { RPCClient, ActivityStatus, ActivityType, DisplayType, } from "@monicode/discord-rpc"; let client; let config; let lastArtUrl = null; let lastData = null; let intentionallyClosed = false; let reconnectTimer = null; // Cache: local file path → { url, uploadedAt } const artCache = new Map(); const CACHE_TTL_MS = 50 * 60 * 1000; // 50 minutes (uploads expire at 60min) function getCachedArtUrl(artUrl) { if (!artUrl || !artUrl.startsWith("file://")) return artUrl; const filePath = new URL(artUrl).pathname; const cached = artCache.get(filePath); if (cached && Date.now() - cached.uploadedAt < CACHE_TTL_MS) { return cached.url; } return null; } async function uploadArt(artUrl) { const filePath = new URL(artUrl).pathname; try { const fileData = await readFile(filePath); let filename = basename(filePath); if (!filename.includes(".")) { filename += ".png"; } const form = new FormData(); form.append("file", new Blob([fileData]), filename); const res = await fetch("https://tmpfiles.org/api/v1/upload", { method: "POST", body: form, }); if (!res.ok) { console.error(`[discord] tmpfiles.org upload failed: ${res.status}`); return null; } const json = await res.json(); const url = json.data.url.replace("tmpfiles.org/", "tmpfiles.org/dl/"); artCache.set(filePath, { url, uploadedAt: Date.now() }); console.log(`[discord] Uploaded art to ${url}`); return url; } catch (err) { console.error(`[discord] Failed to upload art: ${err.message}`); return null; } } async function connect() { client = new RPCClient(config.clientId, false); await client.init(); console.log("[discord] Connected to Discord RPC"); } function scheduleReconnect(delayMs = 5000) { if (intentionallyClosed || reconnectTimer) return; console.log(`[discord] Scheduling reconnect in ${delayMs / 1000}s...`); reconnectTimer = setTimeout(async () => { reconnectTimer = null; if (intentionallyClosed) return; try { await connect(); console.log("[discord] Reconnected to Discord RPC"); if (lastData) { await onData(lastData); } } catch (err) { console.error(`[discord] Reconnect failed: ${err.message}`); scheduleReconnect(Math.min(delayMs * 2, 60000)); } }, delayMs); } export async function init(cfg) { if (!cfg || !cfg.clientId) { throw new Error("Missing config/discord.json (needs clientId)"); } config = cfg; intentionallyClosed = false; await connect(); } export async function onData(data) { if (!client) return; lastData = data; const artistString = data.artists.map((a) => a.artistName).join(", "); const paused = data.playbackStatus !== "Playing"; function buildActivity(imageUrl, forceNoTimestamps = false) { const activity = new ActivityStatus( config.name ?? "Music", ActivityType.LISTENING, ); activity.setDetails(data.trackName); activity.setState(paused ? "Paused" : `by ${artistString}`); activity.setStatusDisplayType(DisplayType.DETAILS); if (!paused && !forceNoTimestamps) { const endMs = new Date(data.expiry).getTime(); const startMs = endMs - data.duration * 1000; activity.setTimestamps({ start: Math.floor(startMs / 1000), end: Math.floor(endMs / 1000), }); } const largeImage = imageUrl || config.largeImage || undefined; if (largeImage) { activity.setAssets({ large_image: largeImage, large_text: data.releaseName || undefined, }); } if (config.buttons?.length) { activity.setButtons(config.buttons); } return activity; } // Use cached URL if available, otherwise fallback const artUrl = data.artUrl || lastArtUrl; const cachedUrl = getCachedArtUrl(artUrl); // On seek, send a completely different activity so Discord resets timestamps if (data.seeked && !paused) { try { client.setActivity( new ActivityStatus("Idle", ActivityType.LISTENING), ); } catch { // ignore } await new Promise((r) => setTimeout(r, 250)); } try { client.setActivity(buildActivity(cachedUrl, false)); } catch (err) { console.error(`[discord] Failed to set activity: ${err.message}`); client = null; scheduleReconnect(); return; } // If artUrl is a local file and wasn't cached, upload in background and update if (artUrl?.startsWith("file://") && !cachedUrl) { uploadArt(artUrl).then((url) => { if (url && client) { try { client.setActivity(buildActivity(url, false)); } catch { // ignore } } }); } if (data.artUrl) lastArtUrl = data.artUrl; } export function onClear() { intentionallyClosed = true; lastData = null; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (!client) return; try { client.close(); console.log("[discord] Cleared activity (player gone)"); } catch { // ignore } client = null; }