Pipris is an extensible MPRIS scrobbler written with Deno.

0.0.3: add discord, clean up some code, slightly better readme

+278 -28
+3 -1
.gitignore
··· 1 - config/*.json 2 1 node_modules/ 2 + 3 + config/*.json 4 + !config/global.json
+36 -4
README.md
··· 1 - # Pipris ![Version 0.0.2](https://img.shields.io/badge/version-0.0.2-blue) 1 + # Pipris ![Version 0.0.3](https://img.shields.io/badge/version-0.0.3-blue) 2 2 3 - Pipris is an extensible MPRIS scrobbler written in Deno. It was originally created for 3 + Pipris is an extensible MPRIS scrobbler written with Deno. It was originally created for 4 4 [teal.fm](https://teal.fm), but can be extended to support a wide variety of 5 5 services (e.g. Last.fm or Discord rich presence). 6 6 7 7 The name is derived from [piper](https://tangled.org/teal.fm/piper) + 8 8 [MPRIS](https://specifications.freedesktop.org/mpris/latest). 9 9 10 - ## Usage 10 + ## Development 11 + 12 + The following modules are built in: 11 13 12 - There is an example module in `src/modules/example.js.disabled`, and a teal.fm module in `src/modules/teal.js`. 14 + - <code>src/modules/<ins>**discord**</ins>.js</code> 15 + 16 + Discord rich presence 17 + 18 + - <code>src/modules/<ins>**teal**</ins>.js</code> 19 + 20 + teal.fm scrobbler 21 + 22 + - <code>src/modules/<ins>**example**</ins>.js.disabled</code> 23 + 24 + Example module stub 25 + 26 + ## Configuration 27 + 28 + For every module, there is a matching *.json file in the `config` folder. Most options should be self-explanatory, though some will be listed below for clarity. 29 + 30 + ### global.json 31 + 32 + `global.json` currently has 1 option: `"playerListMode"`. This can be set to one of three options: 33 + 34 + - `"on"` 35 + 36 + Will only capture MPRIS events from whitelisted players (defined in `src/mpris.js`). 37 + 38 + - `"priority"` 39 + 40 + All MPRIS events will be captured, but ones coming from players in the player list will be prioritised. 41 + 42 + - `"off"` 43 + 44 + The player list will be completely disabled.
+6
config/discord.json.example
··· 1 + { 2 + "clientId": "1478199343784919293", 3 + "name": "Music", 4 + "largeImage": "fallback", 5 + "buttons": [] 6 + }
+3
config/global.json
··· 1 + { 2 + "playerListMode": "on" 3 + }
+1
deno.json
··· 5 5 "imports": { 6 6 "@atcute/client": "npm:@atcute/client@^4.2.1", 7 7 "@atcute/password-session": "npm:@atcute/password-session@^0.1.0", 8 + "@monicode/discord-rpc": "jsr:@monicode/discord-rpc@^0.1.5", 8 9 "dbus-next": "npm:dbus-next@^0.10.2" 9 10 }, 10 11 "nodeModulesDir": "auto",
+7
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@monicode/discord-rpc@~0.1.5": "0.1.5", 4 5 "npm:@atcute/client@^4.2.1": "4.2.1", 5 6 "npm:@atcute/password-session@0.1": "0.1.0", 6 7 "npm:dbus-next@~0.10.2": "0.10.2" 8 + }, 9 + "jsr": { 10 + "@monicode/discord-rpc@0.1.5": { 11 + "integrity": "50bdd6e46ceb7fe47fcda7fbee94f3fc9cfa7004de62233b11a4e9c3ad51336e" 12 + } 7 13 }, 8 14 "npm": { 9 15 "@atcute/client@4.2.1": { ··· 680 686 }, 681 687 "workspace": { 682 688 "dependencies": [ 689 + "jsr:@monicode/discord-rpc@~0.1.5", 683 690 "npm:@atcute/client@^4.2.1", 684 691 "npm:@atcute/password-session@0.1", 685 692 "npm:dbus-next@~0.10.2"
+2
src/formatter.js
··· 15 15 16 16 const artistList = metaStringArray(metadata, "xesam:artist"); 17 17 const album = metaString(metadata, "xesam:album") ?? ""; 18 + const artUrl = metaString(metadata, "mpris:artUrl") ?? ""; 18 19 const lengthUs = metaInt(metadata, "mpris:length") ?? 0; 19 20 20 21 const now = Date.now(); ··· 35 36 playbackStatus, 36 37 playedTime, 37 38 releaseName: album, 39 + artUrl, 38 40 duration: Math.round(lengthMs / 1000), 39 41 expiry, 40 42 };
+46 -13
src/index.js
··· 14 14 const modulesDir = path.resolve(import.meta.dirname, "modules"); 15 15 const configDir = path.resolve(import.meta.dirname, "../config"); 16 16 let modules = []; 17 + let globalConfig = {}; 18 + 19 + async function loadGlobalConfig() { 20 + const configPath = path.join(configDir, "global.json"); 21 + try { 22 + globalConfig = JSON.parse(await readFile(configPath, "utf-8")); 23 + } catch { 24 + globalConfig = {}; 25 + } 26 + } 17 27 18 28 async function loadConfig(moduleName) { 19 29 const baseName = moduleName.replace(/\.js$/, ""); ··· 35 45 try { 36 46 const mod = await import(url); 37 47 if (typeof mod.onData !== "function") { 38 - console.warn(`[tealMpris] Skipping ${file} (no onData export)`); 48 + console.warn(`[pipris] Skipping ${file} (no onData export)`); 39 49 continue; 40 50 } 41 51 ··· 50 60 onData: mod.onData, 51 61 onClear: typeof mod.onClear === "function" ? mod.onClear : null, 52 62 }); 53 - console.log(`[tealMpris] Loaded module: ${file}`); 63 + console.log(`[pipris] Loaded module: ${file}`); 54 64 } catch (err) { 55 - console.error(`[tealMpris] Failed to load ${file}: ${err.message}`); 65 + console.error(`[pipris] Failed to load ${file}: ${err.message}`); 56 66 } 57 67 } 58 68 } ··· 62 72 try { 63 73 await mod.onData(data); 64 74 } catch (err) { 65 - console.error(`[tealMpris] Module ${mod.name} error: ${err.message}`); 75 + console.error(`[pipris] Module ${mod.name} error: ${err.message}`); 66 76 } 67 77 } 68 78 } ··· 73 83 try { 74 84 await mod.onClear(); 75 85 } catch (err) { 76 - console.error(`[tealMpris] Module ${mod.name} onClear error: ${err.message}`); 86 + console.error(`[pipris] Module ${mod.name} onClear error: ${err.message}`); 77 87 } 78 88 } 79 89 } ··· 110 120 callModules(data); 111 121 } 112 122 } catch (err) { 113 - console.error(`[tealMpris] Failed to read player data: ${err.message}`); 123 + console.error(`[pipris] Failed to read player data: ${err.message}`); 114 124 } 115 125 } 116 126 ··· 129 139 } 130 140 131 141 activePlayer = playerName; 132 - console.log(`[tealMpris] Attached to ${playerName}`); 142 + console.log(`[pipris] Attached to ${playerName}`); 133 143 134 144 await emitCurrentState(); 135 145 ··· 155 165 156 166 if (players.length === 0) { 157 167 if (activePlayer) { 158 - console.log("[tealMpris] No MPRIS players found. Waiting..."); 168 + console.log("[pipris] No MPRIS players found. Waiting..."); 159 169 if (cleanupWatch) { 160 170 cleanupWatch(); 161 171 cleanupWatch = null; ··· 168 178 return; 169 179 } 170 180 171 - const best = selectPlayer(players); 181 + const best = selectPlayer(players, globalConfig.playerListMode); 182 + 183 + if (!best) { 184 + if (activePlayer) { 185 + console.log("[pipris] No whitelisted players found. Waiting..."); 186 + if (cleanupWatch) { 187 + cleanupWatch(); 188 + cleanupWatch = null; 189 + } 190 + activePlayer = null; 191 + currentTrackId = null; 192 + currentPlayedTime = null; 193 + await callModulesClear(); 194 + } 195 + return; 196 + } 172 197 173 198 // Only re-attach if the selected player changed 174 199 if (best !== activePlayer) { 175 - await attachToPlayer(best); 200 + try { 201 + await attachToPlayer(best); 202 + } catch (err) { 203 + console.error( 204 + `[pipris] Failed to attach to ${best}: ${err.message}`, 205 + ); 206 + activePlayer = null; 207 + } 176 208 } 177 209 } 178 210 179 211 async function main() { 212 + await loadGlobalConfig(); 180 213 await loadModules(); 181 214 182 215 bus = sessionBus(); ··· 184 217 // Watch for players appearing / disappearing 185 218 await watchBus(bus, () => { 186 219 scan().catch((err) => 187 - console.error(`[tealMpris] Scan error: ${err.message}`), 220 + console.error(`[pipris] Scan error: ${err.message}`), 188 221 ); 189 222 }); 190 223 ··· 192 225 await scan(); 193 226 194 227 if (!activePlayer) { 195 - console.log("[tealMpris] No MPRIS players found. Waiting for one to start..."); 228 + console.log("[pipris] No MPRIS players found. Waiting for one to start..."); 196 229 } 197 230 } 198 231 199 232 main().catch((err) => { 200 - console.error(`[tealMpris] Fatal: ${err.message}`); 233 + console.error(`[pipris] Fatal: ${err.message}`); 201 234 process.exit(1); 202 235 });
+142
src/modules/discord.js
··· 1 + import { readFile } from "node:fs/promises"; 2 + import { basename } from "node:path"; 3 + import { 4 + RPCClient, 5 + ActivityStatus, 6 + ActivityType, 7 + DisplayType, 8 + } from "@monicode/discord-rpc"; 9 + 10 + let client; 11 + let config; 12 + let lastArtUrl = null; 13 + 14 + // Cache: local file path → { url, uploadedAt } 15 + const artCache = new Map(); 16 + const CACHE_TTL_MS = 50 * 60 * 1000; // 50 minutes (uploads expire at 60min) 17 + 18 + function getCachedArtUrl(artUrl) { 19 + if (!artUrl || !artUrl.startsWith("file://")) return artUrl; 20 + const filePath = new URL(artUrl).pathname; 21 + const cached = artCache.get(filePath); 22 + if (cached && Date.now() - cached.uploadedAt < CACHE_TTL_MS) { 23 + return cached.url; 24 + } 25 + return null; 26 + } 27 + 28 + async function uploadArt(artUrl) { 29 + const filePath = new URL(artUrl).pathname; 30 + try { 31 + const fileData = await readFile(filePath); 32 + let filename = basename(filePath); 33 + if (!filename.includes(".")) { 34 + filename += ".png"; 35 + } 36 + const form = new FormData(); 37 + form.append("file", new Blob([fileData]), filename); 38 + 39 + const res = await fetch("https://tmpfiles.org/api/v1/upload", { 40 + method: "POST", 41 + body: form, 42 + }); 43 + if (!res.ok) { 44 + console.error(`[discord] tmpfiles.org upload failed: ${res.status}`); 45 + return null; 46 + } 47 + 48 + const json = await res.json(); 49 + const url = json.data.url.replace("tmpfiles.org/", "tmpfiles.org/dl/"); 50 + artCache.set(filePath, { url, uploadedAt: Date.now() }); 51 + console.log(`[discord] Uploaded art to ${url}`); 52 + return url; 53 + } catch (err) { 54 + console.error(`[discord] Failed to upload art: ${err.message}`); 55 + return null; 56 + } 57 + } 58 + 59 + export async function init(cfg) { 60 + if (!cfg || !cfg.clientId) { 61 + throw new Error("Missing config/discord.json (needs clientId)"); 62 + } 63 + config = cfg; 64 + 65 + client = new RPCClient(config.clientId, false); 66 + await client.init(); 67 + console.log("[discord] Connected to Discord RPC"); 68 + } 69 + 70 + export async function onData(data) { 71 + if (!client) return; 72 + 73 + const artistString = data.artists.map((a) => a.artistName).join(", "); 74 + const paused = data.playbackStatus !== "Playing"; 75 + 76 + function buildActivity(imageUrl) { 77 + const activity = new ActivityStatus( 78 + config.name ?? "Music", 79 + ActivityType.LISTENING, 80 + ); 81 + activity.setDetails(data.trackName); 82 + activity.setState(paused ? "Paused" : `by ${artistString}`); 83 + activity.setStatusDisplayType(DisplayType.DETAILS); 84 + 85 + if (!paused) { 86 + const endMs = new Date(data.expiry).getTime(); 87 + const startMs = endMs - data.duration * 1000; 88 + activity.setTimestamps({ 89 + start: Math.floor(startMs / 1000), 90 + end: Math.floor(endMs / 1000), 91 + }); 92 + } 93 + 94 + const largeImage = imageUrl || config.largeImage || undefined; 95 + if (largeImage) { 96 + activity.setAssets({ 97 + large_image: largeImage, 98 + large_text: data.releaseName || undefined, 99 + }); 100 + } 101 + 102 + if (config.buttons?.length) { 103 + activity.setButtons(config.buttons); 104 + } 105 + return activity; 106 + } 107 + 108 + // Use cached URL if available, otherwise fallback 109 + const artUrl = data.artUrl || lastArtUrl; 110 + const cachedUrl = getCachedArtUrl(artUrl); 111 + try { 112 + client.setActivity(buildActivity(cachedUrl)); 113 + } catch (err) { 114 + console.error(`[discord] Failed to set activity: ${err.message}`); 115 + } 116 + 117 + // If artUrl is a local file and wasn't cached, upload in background and update 118 + if (artUrl?.startsWith("file://") && !cachedUrl) { 119 + uploadArt(artUrl).then((url) => { 120 + if (url && client) { 121 + try { 122 + client.setActivity(buildActivity(url)); 123 + } catch { 124 + // ignore 125 + } 126 + } 127 + }); 128 + } 129 + 130 + if (data.artUrl) lastArtUrl = data.artUrl; 131 + } 132 + 133 + export function onClear() { 134 + if (!client) return; 135 + try { 136 + client.close(); 137 + console.log("[discord] Cleared activity (player gone)"); 138 + } catch { 139 + // ignore 140 + } 141 + client = null; 142 + }
+1 -1
src/modules/teal.js
··· 1 1 import { Client } from "@atcute/client"; 2 2 import { PasswordSession } from "@atcute/password-session"; 3 3 4 - const AGENT_STRING = "pipris/0.0.2 (https://tangled.org/clay.rip/pipris)"; 4 + const AGENT_STRING = "pipris/0.0.3 (https://tangled.org/clay.rip/pipris)"; 5 5 const PLAY_FINISH_TOLERANCE_MS = 5000; 6 6 7 7 let rpc;
+31 -9
src/mpris.js
··· 5 5 const PROPS_IFACE = "org.freedesktop.DBus.Properties"; 6 6 7 7 // Lowercase substrings matched against the bus name suffix. 8 - // These are local/offline music players that should be preferred. 9 - const PREFERRED_PLAYERS = [ 8 + // Only players matching this whitelist will be used. 9 + const ALLOWED_PLAYERS = [ 10 + // local players 10 11 "gapless", 11 12 "elisa", 12 13 "rhythmbox", ··· 19 20 "audacious", 20 21 "quodlibet", 21 22 "deadbeef", 22 - "celluloid", 23 23 "cmus", 24 24 "musikcube", 25 25 "aimp", 26 + "spotify", 27 + "tidal", 28 + "deezer", 29 + "youtube-music", 30 + "youtubemusic", 31 + "nuclear", 32 + "cider", 33 + "feishin", 34 + "sonixd", 35 + "navidrome", 36 + "plexamp", 26 37 ]; 27 38 28 39 /** ··· 46 57 } 47 58 48 59 /** 49 - * Pick the best player from a list of bus names. 50 - * Prefers local music players; falls back to the first in the list. 60 + * Pick the best player from a list of bus names based on the list mode. 61 + * "off" – return the first player found (ignore the list) 62 + * "priority" – prefer listed players, fall back to any player 63 + * "on" – only allow listed players (strict whitelist) 51 64 */ 52 - export function selectPlayer(players) { 53 - if (players.length === 0) return null; 65 + export function selectPlayer(players, mode = "on") { 66 + if (mode === "off") { 67 + return players[0] ?? null; 68 + } 54 69 70 + // Find the first player that matches the list 55 71 for (const name of players) { 56 72 const suffix = name.slice(MPRIS_PREFIX.length).toLowerCase(); 57 - if (PREFERRED_PLAYERS.some((p) => suffix.includes(p))) { 73 + if (ALLOWED_PLAYERS.some((p) => suffix.includes(p))) { 58 74 return name; 59 75 } 60 76 } 61 - return players[0]; 77 + 78 + // In priority mode, fall back to any player 79 + if (mode === "priority") { 80 + return players[0] ?? null; 81 + } 82 + 83 + return null; 62 84 } 63 85 64 86 /**