diff --git a/.gitignore b/.gitignore index b1f0d84..b6e3268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -config/*.json node_modules/ + +config/*.json +!config/global.json diff --git a/README.md b/README.md index c36e9ad..c22a0e0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,44 @@ -# Pipris ![Version 0.0.2](https://img.shields.io/badge/version-0.0.2-blue) +# Pipris ![Version 0.0.3](https://img.shields.io/badge/version-0.0.3-blue) -Pipris is an extensible MPRIS scrobbler written in Deno. It was originally created for +Pipris is an extensible MPRIS scrobbler written with Deno. It was originally created for [teal.fm](https://teal.fm), but can be extended to support a wide variety of services (e.g. Last.fm or Discord rich presence). The name is derived from [piper](https://tangled.org/teal.fm/piper) + [MPRIS](https://specifications.freedesktop.org/mpris/latest). -## Usage +## Development -There is an example module in `src/modules/example.js.disabled`, and a teal.fm module in `src/modules/teal.js`. +The following modules are built in: + +- src/modules/**discord**.js + + Discord rich presence + +- src/modules/**teal**.js + + teal.fm scrobbler + +- src/modules/**example**.js.disabled + + Example module stub + +## Configuration + +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. + +### global.json + +`global.json` currently has 1 option: `"playerListMode"`. This can be set to one of three options: + +- `"on"` + + Will only capture MPRIS events from whitelisted players (defined in `src/mpris.js`). + +- `"priority"` + + All MPRIS events will be captured, but ones coming from players in the player list will be prioritised. + +- `"off"` + + The player list will be completely disabled. diff --git a/config/discord.json.example b/config/discord.json.example new file mode 100644 index 0000000..d9653d2 --- /dev/null +++ b/config/discord.json.example @@ -0,0 +1,6 @@ +{ + "clientId": "1478199343784919293", + "name": "Music", + "largeImage": "fallback", + "buttons": [] +} diff --git a/config/global.json b/config/global.json new file mode 100644 index 0000000..a3b38c2 --- /dev/null +++ b/config/global.json @@ -0,0 +1,3 @@ +{ + "playerListMode": "on" +} diff --git a/deno.json b/deno.json index 185143d..79a4a5c 100644 --- a/deno.json +++ b/deno.json @@ -5,6 +5,7 @@ "imports": { "@atcute/client": "npm:@atcute/client@^4.2.1", "@atcute/password-session": "npm:@atcute/password-session@^0.1.0", + "@monicode/discord-rpc": "jsr:@monicode/discord-rpc@^0.1.5", "dbus-next": "npm:dbus-next@^0.10.2" }, "nodeModulesDir": "auto", diff --git a/deno.lock b/deno.lock index 6872aac..f892c2b 100644 --- a/deno.lock +++ b/deno.lock @@ -1,10 +1,16 @@ { "version": "5", "specifiers": { + "jsr:@monicode/discord-rpc@~0.1.5": "0.1.5", "npm:@atcute/client@^4.2.1": "4.2.1", "npm:@atcute/password-session@0.1": "0.1.0", "npm:dbus-next@~0.10.2": "0.10.2" }, + "jsr": { + "@monicode/discord-rpc@0.1.5": { + "integrity": "50bdd6e46ceb7fe47fcda7fbee94f3fc9cfa7004de62233b11a4e9c3ad51336e" + } + }, "npm": { "@atcute/client@4.2.1": { "integrity": "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==", @@ -680,6 +686,7 @@ }, "workspace": { "dependencies": [ + "jsr:@monicode/discord-rpc@~0.1.5", "npm:@atcute/client@^4.2.1", "npm:@atcute/password-session@0.1", "npm:dbus-next@~0.10.2" diff --git a/src/formatter.js b/src/formatter.js index dc596c0..f0a79f3 100644 --- a/src/formatter.js +++ b/src/formatter.js @@ -15,6 +15,7 @@ export function formatTrackData(metadata, playbackStatus, positionUs, playedTime const artistList = metaStringArray(metadata, "xesam:artist"); const album = metaString(metadata, "xesam:album") ?? ""; + const artUrl = metaString(metadata, "mpris:artUrl") ?? ""; const lengthUs = metaInt(metadata, "mpris:length") ?? 0; const now = Date.now(); @@ -35,6 +36,7 @@ export function formatTrackData(metadata, playbackStatus, positionUs, playedTime playbackStatus, playedTime, releaseName: album, + artUrl, duration: Math.round(lengthMs / 1000), expiry, }; diff --git a/src/index.js b/src/index.js index 646ab93..0fbc8b3 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,16 @@ import { formatTrackData } from "./formatter.js"; const modulesDir = path.resolve(import.meta.dirname, "modules"); const configDir = path.resolve(import.meta.dirname, "../config"); let modules = []; +let globalConfig = {}; + +async function loadGlobalConfig() { + const configPath = path.join(configDir, "global.json"); + try { + globalConfig = JSON.parse(await readFile(configPath, "utf-8")); + } catch { + globalConfig = {}; + } +} async function loadConfig(moduleName) { const baseName = moduleName.replace(/\.js$/, ""); @@ -35,7 +45,7 @@ async function loadModules() { try { const mod = await import(url); if (typeof mod.onData !== "function") { - console.warn(`[tealMpris] Skipping ${file} (no onData export)`); + console.warn(`[pipris] Skipping ${file} (no onData export)`); continue; } @@ -50,9 +60,9 @@ async function loadModules() { onData: mod.onData, onClear: typeof mod.onClear === "function" ? mod.onClear : null, }); - console.log(`[tealMpris] Loaded module: ${file}`); + console.log(`[pipris] Loaded module: ${file}`); } catch (err) { - console.error(`[tealMpris] Failed to load ${file}: ${err.message}`); + console.error(`[pipris] Failed to load ${file}: ${err.message}`); } } } @@ -62,7 +72,7 @@ async function callModules(data) { try { await mod.onData(data); } catch (err) { - console.error(`[tealMpris] Module ${mod.name} error: ${err.message}`); + console.error(`[pipris] Module ${mod.name} error: ${err.message}`); } } } @@ -73,7 +83,7 @@ async function callModulesClear() { try { await mod.onClear(); } catch (err) { - console.error(`[tealMpris] Module ${mod.name} onClear error: ${err.message}`); + console.error(`[pipris] Module ${mod.name} onClear error: ${err.message}`); } } } @@ -110,7 +120,7 @@ async function emitCurrentState() { callModules(data); } } catch (err) { - console.error(`[tealMpris] Failed to read player data: ${err.message}`); + console.error(`[pipris] Failed to read player data: ${err.message}`); } } @@ -129,7 +139,7 @@ async function attachToPlayer(playerName) { } activePlayer = playerName; - console.log(`[tealMpris] Attached to ${playerName}`); + console.log(`[pipris] Attached to ${playerName}`); await emitCurrentState(); @@ -155,7 +165,7 @@ async function scan() { if (players.length === 0) { if (activePlayer) { - console.log("[tealMpris] No MPRIS players found. Waiting..."); + console.log("[pipris] No MPRIS players found. Waiting..."); if (cleanupWatch) { cleanupWatch(); cleanupWatch = null; @@ -168,15 +178,38 @@ async function scan() { return; } - const best = selectPlayer(players); + const best = selectPlayer(players, globalConfig.playerListMode); + + if (!best) { + if (activePlayer) { + console.log("[pipris] No whitelisted players found. Waiting..."); + if (cleanupWatch) { + cleanupWatch(); + cleanupWatch = null; + } + activePlayer = null; + currentTrackId = null; + currentPlayedTime = null; + await callModulesClear(); + } + return; + } // Only re-attach if the selected player changed if (best !== activePlayer) { - await attachToPlayer(best); + try { + await attachToPlayer(best); + } catch (err) { + console.error( + `[pipris] Failed to attach to ${best}: ${err.message}`, + ); + activePlayer = null; + } } } async function main() { + await loadGlobalConfig(); await loadModules(); bus = sessionBus(); @@ -184,7 +217,7 @@ async function main() { // Watch for players appearing / disappearing await watchBus(bus, () => { scan().catch((err) => - console.error(`[tealMpris] Scan error: ${err.message}`), + console.error(`[pipris] Scan error: ${err.message}`), ); }); @@ -192,11 +225,11 @@ async function main() { await scan(); if (!activePlayer) { - console.log("[tealMpris] No MPRIS players found. Waiting for one to start..."); + console.log("[pipris] No MPRIS players found. Waiting for one to start..."); } } main().catch((err) => { - console.error(`[tealMpris] Fatal: ${err.message}`); + console.error(`[pipris] Fatal: ${err.message}`); process.exit(1); }); diff --git a/src/modules/discord.js b/src/modules/discord.js new file mode 100644 index 0000000..c01efa7 --- /dev/null +++ b/src/modules/discord.js @@ -0,0 +1,142 @@ +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; + +// 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; + } +} + +export async function init(cfg) { + if (!cfg || !cfg.clientId) { + throw new Error("Missing config/discord.json (needs clientId)"); + } + config = cfg; + + client = new RPCClient(config.clientId, false); + await client.init(); + console.log("[discord] Connected to Discord RPC"); +} + +export async function onData(data) { + if (!client) return; + + const artistString = data.artists.map((a) => a.artistName).join(", "); + const paused = data.playbackStatus !== "Playing"; + + function buildActivity(imageUrl) { + 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) { + 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); + try { + client.setActivity(buildActivity(cachedUrl)); + } catch (err) { + console.error(`[discord] Failed to set activity: ${err.message}`); + } + + // 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)); + } catch { + // ignore + } + } + }); + } + + if (data.artUrl) lastArtUrl = data.artUrl; +} + +export function onClear() { + if (!client) return; + try { + client.close(); + console.log("[discord] Cleared activity (player gone)"); + } catch { + // ignore + } + client = null; +} diff --git a/src/modules/teal.js b/src/modules/teal.js index de095b5..b5945f4 100644 --- a/src/modules/teal.js +++ b/src/modules/teal.js @@ -1,7 +1,7 @@ import { Client } from "@atcute/client"; import { PasswordSession } from "@atcute/password-session"; -const AGENT_STRING = "pipris/0.0.2 (https://tangled.org/clay.rip/pipris)"; +const AGENT_STRING = "pipris/0.0.3 (https://tangled.org/clay.rip/pipris)"; const PLAY_FINISH_TOLERANCE_MS = 5000; let rpc; diff --git a/src/mpris.js b/src/mpris.js index 36c4efd..e88c8dd 100644 --- a/src/mpris.js +++ b/src/mpris.js @@ -5,8 +5,9 @@ const PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"; const PROPS_IFACE = "org.freedesktop.DBus.Properties"; // Lowercase substrings matched against the bus name suffix. -// These are local/offline music players that should be preferred. -const PREFERRED_PLAYERS = [ +// Only players matching this whitelist will be used. +const ALLOWED_PLAYERS = [ + // local players "gapless", "elisa", "rhythmbox", @@ -19,10 +20,20 @@ const PREFERRED_PLAYERS = [ "audacious", "quodlibet", "deadbeef", - "celluloid", "cmus", "musikcube", "aimp", + "spotify", + "tidal", + "deezer", + "youtube-music", + "youtubemusic", + "nuclear", + "cider", + "feishin", + "sonixd", + "navidrome", + "plexamp", ]; /** @@ -46,19 +57,30 @@ export async function listPlayers(bus) { } /** - * Pick the best player from a list of bus names. - * Prefers local music players; falls back to the first in the list. + * Pick the best player from a list of bus names based on the list mode. + * "off" – return the first player found (ignore the list) + * "priority" – prefer listed players, fall back to any player + * "on" – only allow listed players (strict whitelist) */ -export function selectPlayer(players) { - if (players.length === 0) return null; +export function selectPlayer(players, mode = "on") { + if (mode === "off") { + return players[0] ?? null; + } + // Find the first player that matches the list for (const name of players) { const suffix = name.slice(MPRIS_PREFIX.length).toLowerCase(); - if (PREFERRED_PLAYERS.some((p) => suffix.includes(p))) { + if (ALLOWED_PLAYERS.some((p) => suffix.includes(p))) { return name; } } - return players[0]; + + // In priority mode, fall back to any player + if (mode === "priority") { + return players[0] ?? null; + } + + return null; } /**