Pipris is an extensible MPRIS scrobbler written with Deno.
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}