Pipris is an extensible MPRIS scrobbler written with Deno.
1import { readdir, readFile } from "node:fs/promises";
2import { pathToFileURL } from "node:url";
3import path from "node:path";
4import {
5 sessionBus,
6 listPlayers,
7 selectPlayer,
8 getPlayerData,
9 watchPlayer,
10 watchBus,
11} from "./mpris.js";
12import { formatTrackData } from "./formatter.js";
13
14const modulesDir = path.resolve(import.meta.dirname, "modules");
15const configDir = path.resolve(import.meta.dirname, "../config");
16let modules = [];
17let globalConfig = {};
18
19async 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}
27
28async function loadConfig(moduleName) {
29 const baseName = moduleName.replace(/\.js$/, "");
30 const configPath = path.join(configDir, `${baseName}.json`);
31 try {
32 return JSON.parse(await readFile(configPath, "utf-8"));
33 } catch {
34 return null;
35 }
36}
37
38async function loadModules() {
39 const files = await readdir(modulesDir);
40 const jsFiles = files.filter((f) => f.endsWith(".js"));
41
42 modules = [];
43 for (const file of jsFiles) {
44 const url = pathToFileURL(path.join(modulesDir, file)).href;
45 try {
46 const mod = await import(url);
47 if (typeof mod.onData !== "function") {
48 console.warn(`[pipris] Skipping ${file} (no onData export)`);
49 continue;
50 }
51
52 const config = await loadConfig(file);
53
54 if (typeof mod.init === "function") {
55 await mod.init(config);
56 }
57
58 modules.push({
59 name: file,
60 onData: mod.onData,
61 onClear: typeof mod.onClear === "function" ? mod.onClear : null,
62 });
63 console.log(`[pipris] Loaded module: ${file}`);
64 } catch (err) {
65 console.error(`[pipris] Failed to load ${file}: ${err.message}`);
66 }
67 }
68}
69
70async function callModules(data) {
71 for (const mod of modules) {
72 try {
73 await mod.onData(data);
74 } catch (err) {
75 console.error(`[pipris] Module ${mod.name} error: ${err.message}`);
76 }
77 }
78}
79
80async function callModulesClear() {
81 for (const mod of modules) {
82 if (!mod.onClear) continue;
83 try {
84 await mod.onClear();
85 } catch (err) {
86 console.error(`[pipris] Module ${mod.name} onClear error: ${err.message}`);
87 }
88 }
89}
90
91let bus;
92let activePlayer = null;
93let cleanupWatch = null;
94let currentTrackId = null;
95let currentPlayedTime = null;
96
97async function emitCurrentState(overridePositionUs, seeked = false) {
98 if (!activePlayer) return;
99
100 try {
101 const { metadata, playbackStatus, positionUs } = await getPlayerData(
102 bus,
103 activePlayer,
104 );
105
106 const trackId = metaTrackId(metadata);
107
108 if (trackId !== currentTrackId) {
109 currentTrackId = trackId;
110 currentPlayedTime = new Date().toISOString();
111 }
112
113 const data = formatTrackData(
114 metadata,
115 playbackStatus,
116 overridePositionUs ?? positionUs,
117 currentPlayedTime,
118 );
119 if (data) {
120 data.seeked = seeked;
121 callModules(data);
122 }
123 } catch (err) {
124 console.error(`[pipris] Failed to read player data: ${err.message}`);
125 }
126}
127
128function metaTrackId(metadata) {
129 const id = metadata["mpris:trackid"];
130 if (id) return String(id.value);
131 const title = metadata["xesam:title"];
132 const url = metadata["xesam:url"];
133 return `${title?.value ?? ""}|${url?.value ?? ""}`;
134}
135
136async function attachToPlayer(playerName) {
137 if (cleanupWatch) {
138 cleanupWatch();
139 cleanupWatch = null;
140 }
141
142 activePlayer = playerName;
143 console.log(`[pipris] Attached to ${playerName}`);
144
145 await emitCurrentState();
146
147 cleanupWatch = await watchPlayer(
148 bus,
149 playerName,
150 async (changed) => {
151 if (
152 changed.PlaybackStatus !== undefined ||
153 changed.Metadata !== undefined
154 ) {
155 await emitCurrentState();
156 }
157 },
158 async (positionUs) => {
159 await emitCurrentState(positionUs, true);
160 },
161 );
162}
163
164async function scan() {
165 const players = await listPlayers(bus);
166
167 if (players.length === 0) {
168 if (activePlayer) {
169 console.log("[pipris] No MPRIS players found. Waiting...");
170 if (cleanupWatch) {
171 cleanupWatch();
172 cleanupWatch = null;
173 }
174 activePlayer = null;
175 currentTrackId = null;
176 currentPlayedTime = null;
177 await callModulesClear();
178 }
179 return;
180 }
181
182 const best = selectPlayer(players, globalConfig.playerListMode);
183
184 if (!best) {
185 if (activePlayer) {
186 console.log("[pipris] No whitelisted players found. Waiting...");
187 if (cleanupWatch) {
188 cleanupWatch();
189 cleanupWatch = null;
190 }
191 activePlayer = null;
192 currentTrackId = null;
193 currentPlayedTime = null;
194 await callModulesClear();
195 }
196 return;
197 }
198
199 // Only re-attach if the selected player changed
200 if (best !== activePlayer) {
201 try {
202 await attachToPlayer(best);
203 } catch (err) {
204 console.error(
205 `[pipris] Failed to attach to ${best}: ${err.message}`,
206 );
207 activePlayer = null;
208 }
209 }
210}
211
212async function main() {
213 await loadGlobalConfig();
214 await loadModules();
215
216 bus = sessionBus();
217
218 // Watch for players appearing / disappearing
219 await watchBus(bus, () => {
220 scan().catch((err) =>
221 console.error(`[pipris] Scan error: ${err.message}`),
222 );
223 });
224
225 // Initial scan
226 await scan();
227
228 if (!activePlayer) {
229 console.log("[pipris] No MPRIS players found. Waiting for one to start...");
230 }
231}
232
233main().catch((err) => {
234 console.error(`[pipris] Fatal: ${err.message}`);
235 process.exit(1);
236});