Pipris is an extensible MPRIS scrobbler written with Deno.
at main 176 lines 4.1 kB view raw
1import { Client } from "@atcute/client"; 2import { PasswordSession } from "@atcute/password-session"; 3 4const AGENT_STRING = "pipris/0.1.1 (https://tangled.org/clay.rip/pipris)"; 5const PLAY_FINISH_TOLERANCE_MS = 5000; 6 7let rpc; 8let did; 9let prevTrack = null; 10 11// --- TID generation (AT Protocol timestamp-based ID) --- 12 13const B32_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 14let lastTimestamp = 0; 15let clockId = Math.floor(Math.random() * 1024); 16 17function generateTid() { 18 let timestamp = Date.now() * 1000; // microseconds 19 if (timestamp <= lastTimestamp) { 20 timestamp = lastTimestamp + 1; 21 } 22 lastTimestamp = timestamp; 23 24 const id = BigInt(timestamp) << 10n | BigInt(clockId); 25 let encoded = ""; 26 let val = id; 27 for (let i = 0; i < 13; i++) { 28 encoded = B32_CHARS[Number(val & 31n)] + encoded; 29 val >>= 5n; 30 } 31 return encoded; 32} 33 34// --- Initialisation --- 35 36export async function init(config) { 37 if (!config) { 38 throw new Error("Missing config/teal.json"); 39 } 40 41 const session = await PasswordSession.login({ 42 service: config.service, 43 identifier: config.handle, 44 password: config.password, 45 }); 46 47 did = session.did; 48 rpc = new Client({ handler: session }); 49 50 console.log("[teal] Logged in as", did); 51} 52 53// --- Status update --- 54 55async function updateStatus(data) { 56 await rpc.post("com.atproto.repo.putRecord", { 57 input: { 58 repo: did, 59 collection: "fm.teal.alpha.actor.status", 60 rkey: "self", 61 record: { 62 $type: "fm.teal.alpha.actor.status", 63 time: data.playedTime, 64 expiry: data.expiry, 65 item: { 66 trackName: data.trackName, 67 artists: data.artists, 68 releaseName: data.releaseName || undefined, 69 duration: data.duration || undefined, 70 playedTime: data.playedTime, 71 musicServiceBaseDomain: "local", 72 submissionClientAgent: AGENT_STRING, 73 }, 74 }, 75 }, 76 }); 77} 78 79async function clearStatus() { 80 const now = new Date(); 81 const expired = new Date(now.getTime() - 60000); 82 await rpc.post("com.atproto.repo.putRecord", { 83 input: { 84 repo: did, 85 collection: "fm.teal.alpha.actor.status", 86 rkey: "self", 87 record: { 88 $type: "fm.teal.alpha.actor.status", 89 time: expired.toISOString(), 90 expiry: expired.toISOString(), 91 item: { 92 trackName: "", 93 artists: [], 94 }, 95 }, 96 }, 97 }); 98} 99 100// --- Play submission --- 101 102async function submitPlay(track) { 103 await rpc.post("com.atproto.repo.createRecord", { 104 input: { 105 repo: did, 106 collection: "fm.teal.alpha.feed.play", 107 rkey: generateTid(), 108 record: { 109 $type: "fm.teal.alpha.feed.play", 110 trackName: track.trackName, 111 artists: track.artists, 112 releaseName: track.releaseName || undefined, 113 duration: track.duration || undefined, 114 playedTime: track.playedTime, 115 musicServiceBaseDomain: "local", 116 submissionClientAgent: AGENT_STRING, 117 }, 118 }, 119 }); 120 console.log(`[teal] Submitted play: ${track.trackName}`); 121} 122 123function shouldSubmitPlay(prev) { 124 if (!prev) return false; 125 const expiryMs = new Date(prev.expiry).getTime(); 126 return Date.now() >= expiryMs - PLAY_FINISH_TOLERANCE_MS; 127} 128 129// --- Module entry point --- 130 131export async function onData(data) { 132 if (!rpc) return; 133 134 const trackKey = `${data.trackName}|${data.artists.map((a) => a.artistName).join(",")}`; 135 const prevKey = prevTrack 136 ? `${prevTrack.trackName}|${prevTrack.artists.map((a) => a.artistName).join(",")}` 137 : null; 138 139 if (trackKey !== prevKey) { 140 if (shouldSubmitPlay(prevTrack)) { 141 try { 142 await submitPlay(prevTrack); 143 } catch (err) { 144 console.error(`[teal] Failed to submit play: ${err.message}`); 145 } 146 } 147 prevTrack = { ...data }; 148 } else { 149 prevTrack = { ...data }; 150 } 151 152 if (data.playbackStatus !== "Playing") { 153 try { 154 await clearStatus(); 155 } catch (err) { 156 console.error(`[teal] Failed to clear status: ${err.message}`); 157 } 158 return; 159 } 160 161 try { 162 await updateStatus(data); 163 } catch (err) { 164 console.error(`[teal] Failed to update status: ${err.message}`); 165 } 166} 167 168export async function onClear() { 169 if (!rpc) return; 170 try { 171 await clearStatus(); 172 console.log("[teal] Cleared status (player gone)"); 173 } catch (err) { 174 console.error(`[teal] Failed to clear status: ${err.message}`); 175 } 176}