Pipris is an extensible MPRIS scrobbler written with Deno.
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}