Pipris is an extensible MPRIS scrobbler written with Deno.
1import dbus from "dbus-next";
2
3const MPRIS_PREFIX = "org.mpris.MediaPlayer2.";
4const PLAYER_IFACE = "org.mpris.MediaPlayer2.Player";
5const PROPS_IFACE = "org.freedesktop.DBus.Properties";
6
7// Lowercase substrings matched against the bus name suffix.
8// Only players matching this whitelist will be used.
9const ALLOWED_PLAYERS = [
10 // local players
11 "gapless",
12 "elisa",
13 "rhythmbox",
14 "g4music",
15 "lollypop",
16 "amberol",
17 "gnome-music",
18 "strawberry",
19 "clementine",
20 "audacious",
21 "quodlibet",
22 "deadbeef",
23 "cmus",
24 "musikcube",
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",
37];
38
39/**
40 * Return the session bus connection.
41 */
42export function sessionBus() {
43 return dbus.sessionBus();
44}
45
46/**
47 * List all MPRIS player bus names currently registered.
48 */
49export async function listPlayers(bus) {
50 const dbusProxy = await bus.getProxyObject(
51 "org.freedesktop.DBus",
52 "/org/freedesktop/DBus",
53 );
54 const iface = dbusProxy.getInterface("org.freedesktop.DBus");
55 const names = await iface.ListNames();
56 return names.filter((n) => n.startsWith(MPRIS_PREFIX));
57}
58
59/**
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)
64 */
65export function selectPlayer(players, mode = "on") {
66 if (mode === "off") {
67 return players[0] ?? null;
68 }
69
70 // Find the first player that matches the list
71 for (const name of players) {
72 const suffix = name.slice(MPRIS_PREFIX.length).toLowerCase();
73 if (ALLOWED_PLAYERS.some((p) => suffix.includes(p))) {
74 return name;
75 }
76 }
77
78 // In priority mode, fall back to any player
79 if (mode === "priority") {
80 return players[0] ?? null;
81 }
82
83 return null;
84}
85
86/**
87 * Read Metadata, PlaybackStatus, and Position from a player.
88 */
89export async function getPlayerData(bus, playerName) {
90 const proxy = await bus.getProxyObject(playerName, "/org/mpris/MediaPlayer2");
91 const props = proxy.getInterface(PROPS_IFACE);
92
93 const [metadata, playbackStatus, position] = await Promise.all([
94 props.Get(PLAYER_IFACE, "Metadata"),
95 props.Get(PLAYER_IFACE, "PlaybackStatus"),
96 props.Get(PLAYER_IFACE, "Position"),
97 ]);
98
99 return {
100 metadata: metadata.value, // dict of Variants
101 playbackStatus: playbackStatus.value, // string
102 positionUs: Number(position.value), // microseconds
103 };
104}
105
106/**
107 * Subscribe to PropertiesChanged and Seeked signals on the player interface.
108 * `onChange` receives (changedProperties: object, invalidated: string[]).
109 * `onSeeked` receives (positionUs: number).
110 * Returns a cleanup function.
111 */
112export async function watchPlayer(bus, playerName, onChange, onSeeked) {
113 const proxy = await bus.getProxyObject(playerName, "/org/mpris/MediaPlayer2");
114 const props = proxy.getInterface(PROPS_IFACE);
115 const player = proxy.getInterface(PLAYER_IFACE);
116
117 const propsHandler = (ifaceName, changed, invalidated) => {
118 if (ifaceName === PLAYER_IFACE) {
119 onChange(changed, invalidated);
120 }
121 };
122
123 const seekHandler = (positionUs) => {
124 onSeeked(Number(positionUs));
125 };
126
127 props.on("PropertiesChanged", propsHandler);
128 player.on("Seeked", seekHandler);
129
130 return () => {
131 props.off("PropertiesChanged", propsHandler);
132 player.off("Seeked", seekHandler);
133 };
134}
135
136/**
137 * Watch for new or removed MPRIS players on the bus.
138 * `onPlayerChange` is called with no arguments whenever the set of players changes.
139 */
140export async function watchBus(bus, onPlayerChange) {
141 const dbusProxy = await bus.getProxyObject(
142 "org.freedesktop.DBus",
143 "/org/freedesktop/DBus",
144 );
145 const iface = dbusProxy.getInterface("org.freedesktop.DBus");
146
147 iface.on("NameOwnerChanged", (name, _oldOwner, _newOwner) => {
148 if (name.startsWith(MPRIS_PREFIX)) {
149 onPlayerChange();
150 }
151 });
152}