Pipris is an extensible MPRIS scrobbler written with Deno.
at main 192 lines 4.9 kB view raw
1import { readFile } from "node:fs/promises"; 2import { basename } from "node:path"; 3import { 4 RPCClient, 5 ActivityStatus, 6 ActivityType, 7 DisplayType, 8} from "@monicode/discord-rpc"; 9 10let client; 11let config; 12let lastArtUrl = null; 13let lastData = null; 14let intentionallyClosed = false; 15let reconnectTimer = null; 16 17// Cache: local file path → { url, uploadedAt } 18const artCache = new Map(); 19const CACHE_TTL_MS = 50 * 60 * 1000; // 50 minutes (uploads expire at 60min) 20 21function getCachedArtUrl(artUrl) { 22 if (!artUrl || !artUrl.startsWith("file://")) return artUrl; 23 const filePath = new URL(artUrl).pathname; 24 const cached = artCache.get(filePath); 25 if (cached && Date.now() - cached.uploadedAt < CACHE_TTL_MS) { 26 return cached.url; 27 } 28 return null; 29} 30 31async function uploadArt(artUrl) { 32 const filePath = new URL(artUrl).pathname; 33 try { 34 const fileData = await readFile(filePath); 35 let filename = basename(filePath); 36 if (!filename.includes(".")) { 37 filename += ".png"; 38 } 39 const form = new FormData(); 40 form.append("file", new Blob([fileData]), filename); 41 42 const res = await fetch("https://tmpfiles.org/api/v1/upload", { 43 method: "POST", 44 body: form, 45 }); 46 if (!res.ok) { 47 console.error(`[discord] tmpfiles.org upload failed: ${res.status}`); 48 return null; 49 } 50 51 const json = await res.json(); 52 const url = json.data.url.replace("tmpfiles.org/", "tmpfiles.org/dl/"); 53 artCache.set(filePath, { url, uploadedAt: Date.now() }); 54 console.log(`[discord] Uploaded art to ${url}`); 55 return url; 56 } catch (err) { 57 console.error(`[discord] Failed to upload art: ${err.message}`); 58 return null; 59 } 60} 61 62async function connect() { 63 client = new RPCClient(config.clientId, false); 64 await client.init(); 65 console.log("[discord] Connected to Discord RPC"); 66} 67 68function scheduleReconnect(delayMs = 5000) { 69 if (intentionallyClosed || reconnectTimer) return; 70 console.log(`[discord] Scheduling reconnect in ${delayMs / 1000}s...`); 71 reconnectTimer = setTimeout(async () => { 72 reconnectTimer = null; 73 if (intentionallyClosed) return; 74 try { 75 await connect(); 76 console.log("[discord] Reconnected to Discord RPC"); 77 if (lastData) { 78 await onData(lastData); 79 } 80 } catch (err) { 81 console.error(`[discord] Reconnect failed: ${err.message}`); 82 scheduleReconnect(Math.min(delayMs * 2, 60000)); 83 } 84 }, delayMs); 85} 86 87export async function init(cfg) { 88 if (!cfg || !cfg.clientId) { 89 throw new Error("Missing config/discord.json (needs clientId)"); 90 } 91 config = cfg; 92 intentionallyClosed = false; 93 94 await connect(); 95} 96 97export async function onData(data) { 98 if (!client) return; 99 lastData = data; 100 101 const artistString = data.artists.map((a) => a.artistName).join(", "); 102 const paused = data.playbackStatus !== "Playing"; 103 104 function buildActivity(imageUrl, forceNoTimestamps = false) { 105 const activity = new ActivityStatus( 106 config.name ?? "Music", 107 ActivityType.LISTENING, 108 ); 109 activity.setDetails(data.trackName); 110 activity.setState(paused ? "Paused" : `by ${artistString}`); 111 activity.setStatusDisplayType(DisplayType.DETAILS); 112 113 if (!paused && !forceNoTimestamps) { 114 const endMs = new Date(data.expiry).getTime(); 115 const startMs = endMs - data.duration * 1000; 116 activity.setTimestamps({ 117 start: Math.floor(startMs / 1000), 118 end: Math.floor(endMs / 1000), 119 }); 120 } 121 122 const largeImage = imageUrl || config.largeImage || undefined; 123 if (largeImage) { 124 activity.setAssets({ 125 large_image: largeImage, 126 large_text: data.releaseName || undefined, 127 }); 128 } 129 130 if (config.buttons?.length) { 131 activity.setButtons(config.buttons); 132 } 133 return activity; 134 } 135 136 // Use cached URL if available, otherwise fallback 137 const artUrl = data.artUrl || lastArtUrl; 138 const cachedUrl = getCachedArtUrl(artUrl); 139 140 // On seek, send a completely different activity so Discord resets timestamps 141 if (data.seeked && !paused) { 142 try { 143 client.setActivity( 144 new ActivityStatus("Idle", ActivityType.LISTENING), 145 ); 146 } catch { 147 // ignore 148 } 149 await new Promise((r) => setTimeout(r, 250)); 150 } 151 152 try { 153 client.setActivity(buildActivity(cachedUrl, false)); 154 } catch (err) { 155 console.error(`[discord] Failed to set activity: ${err.message}`); 156 client = null; 157 scheduleReconnect(); 158 return; 159 } 160 161 // If artUrl is a local file and wasn't cached, upload in background and update 162 if (artUrl?.startsWith("file://") && !cachedUrl) { 163 uploadArt(artUrl).then((url) => { 164 if (url && client) { 165 try { 166 client.setActivity(buildActivity(url, false)); 167 } catch { 168 // ignore 169 } 170 } 171 }); 172 } 173 174 if (data.artUrl) lastArtUrl = data.artUrl; 175} 176 177export function onClear() { 178 intentionallyClosed = true; 179 lastData = null; 180 if (reconnectTimer) { 181 clearTimeout(reconnectTimer); 182 reconnectTimer = null; 183 } 184 if (!client) return; 185 try { 186 client.close(); 187 console.log("[discord] Cleared activity (player gone)"); 188 } catch { 189 // ignore 190 } 191 client = null; 192}