Pipris is an extensible MPRIS scrobbler written with Deno.
at main 236 lines 5.2 kB view raw
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});