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 
+# Pipris 
-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;
}
/**