[READ-ONLY] Mirror of https://github.com/scarnecchia/roon-extension-teal-scrobbler. Roon extension that scrobbles qualifying plays to teal.fm via AT Protocol
26 kB
724 lines
1"use strict";
2
3const { EventEmitter } = require("events");
4const { randomInt } = require("crypto");
5
6/* ── Constants ─────────────────────────────────────────────────────── */
7
8/**
9 * Custom base32 alphabet used by AT Protocol TIDs (lowercase, digits 2–7).
10 * @type {string}
11 */
12const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz";
13
14/**
15 * Default identifier for the teal.fm Roon scrobbler, used in the
16 * `submissionClientAgent` field of play records.
17 * @type {string}
18 */
19const CLIENT_AGENT = "fm.teal.roon-scrobbler/0.1.0";
20
21/**
22 * The teal.fm feed.play lexicon collection NSID.
23 * @type {string}
24 */
25const COLLECTION = "fm.teal.alpha.feed.play";
26
27/**
28 * Default handle-resolution endpoint (Bluesky's AppView).
29 * @type {string}
30 */
31const DEFAULT_HANDLE_RESOLVER = "https://bsky.social";
32
33/**
34 * PLC directory base URL for resolving `did:plc:` identifiers.
35 * @type {string}
36 */
37const PLC_DIRECTORY = "https://plc.directory";
38
39/* ── Errors ────────────────────────────────────────────────────────── */
40
41/**
42 * Base class for all teal.fm submission errors. Carries the original
43 * `playData` so callers (e.g. a retry queue in NUM-15) can re-attempt
44 * the submission later.
45 */
46class TealSubmissionError extends Error {
47 /**
48 * @param {string} message Human-readable description.
49 * @param {object} [opts]
50 * @param {number} [opts.status] HTTP status code (if applicable).
51 * @param {*} [opts.body] Parsed response body (if any).
52 * @param {object} [opts.playData] The play record that was being submitted.
53 * @param {string} [opts.retryAfter] Value of the `Retry-After` header (HTTP 429).
54 */
55 constructor(message, opts = {}) {
56 super(message);
57 this.name = "TealSubmissionError";
58 this.status = opts.status;
59 this.body = opts.body;
60 this.playData = opts.playData;
61 this.retryAfter = opts.retryAfter;
62 }
63}
64
65/** Authentication or authorisation failure (HTTP 401 / 403). */
66class TealAuthError extends TealSubmissionError {
67 constructor(message, opts = {}) {
68 super(message, opts);
69 this.name = "TealAuthError";
70 }
71}
72
73/** Rate-limited by the PDS (HTTP 429). See `retryAfter`. */
74class TealRateLimitError extends TealSubmissionError {
75 constructor(message, opts = {}) {
76 super(message, opts);
77 this.name = "TealRateLimitError";
78 }
79}
80
81/* ── TID generation ────────────────────────────────────────────────── */
82
83/**
84 * Generate an AT Protocol TID (timestamp-based identifier).
85 *
86 * A TID encodes the current time in microseconds as 10 base32 characters
87 * (using the custom alphabet) followed by 2 random base32 characters for
88 * collision avoidance within the same microsecond.
89 *
90 * @returns {string} A 12-character TID.
91 */
92function generateTid() {
93 const now = Date.now();
94 let str = "";
95 let ts = now * 1000; // microseconds since epoch
96
97 for (let i = 0; i < 10; i++) {
98 str = TID_ALPHABET[ts & 0x1f] + str;
99 ts = Math.floor(ts / 32);
100 }
101
102 str += TID_ALPHABET[randomInt(32)];
103 str += TID_ALPHABET[randomInt(32)];
104 return str;
105}
106
107/* ── Artist parsing ────────────────────────────────────────────────── */
108
109/**
110 * Parse a Roon `three_line.line1` (artist) string into an array of
111 * individual artist names.
112 *
113 * Roon presents the artist line as a free-form string that may contain
114 * multiple artists separated by commas, ampersands, or "feat." / "ft."
115 * markers. We split on the common delimiters and trim each result.
116 *
117 * @param {string} line1 - The raw artist string from Roon's three_line.
118 * @returns {Array<{ artistName: string }>} Array of artist objects (for the lexicon).
119 */
120function parseArtists(line1) {
121 if (!line1 || typeof line1 !== "string") return [];
122
123 const splitRegex = /\s*[,&/]\s*|\s+(?:feat\.?|ft\.?)\s+/i;
124 return line1
125 .split(splitRegex)
126 .map((s) => s.trim())
127 .filter((s) => s.length > 0)
128 .map((artistName) => ({ artistName }));
129}
130
131/* ── TealClient ────────────────────────────────────────────────────── */
132
133/**
134 * TealClient submits qualifying plays to a user's PDS as
135 * `fm.teal.alpha.feed.play` records via the AT Protocol.
136 *
137 * Authentication uses a Bluesky **app password** (not the account password).
138 * The client resolves the handle → DID → PDS endpoint, creates a session,
139 * and then creates records on the PDS using the access JWT.
140 *
141 * Events emitted:
142 * - "authenticated" { did, pdsEndpoint } Session established after `init()`.
143 * - "submitted" { uri, cid, playData } Record successfully created.
144 * - "error" { error, playData } Submission failed (non-throw path).
145 */
146class TealClient extends EventEmitter {
147 /**
148 * @param {Object} config
149 * @param {string} config.handle Bluesky handle (with or without domain suffix).
150 * @param {string} config.appPassword App password for the PDS.
151 * @param {string} [config.musicServiceBaseDomain] Default musicServiceBaseDomain for records ("local", "tidal.com", ...).
152 * @param {string} [config.userAgent] Override for the submissionClientAgent field.
153 * @param {string} [config.pdsEndpoint] Pre-known PDS URL (skips DID resolution).
154 * @param {string} [config.handleResolver] Base URL for handle resolution (default: bsky.social).
155 */
156 constructor(config = {}) {
157 super();
158
159 /** @type {string|undefined} */
160 this._handle = config.handle;
161 /** @type {string|undefined} */
162 this._appPassword = config.appPassword;
163 /** @type {string} */
164 this._musicServiceBaseDomain = config.musicServiceBaseDomain || "local";
165 /** @type {string} */
166 this._userAgent = config.userAgent || CLIENT_AGENT;
167 /** @type {string|undefined} */
168 this._pdsEndpoint = config.pdsEndpoint;
169 /** @type {string} */
170 this._handleResolver = config.handleResolver || DEFAULT_HANDLE_RESOLVER;
171
172 // Session state (populated by init()).
173 /** @type {string|undefined} */
174 this._did = undefined;
175 /** @type {string|undefined} */
176 this._accessJwt = undefined;
177 /** @type {string|undefined} */
178 this._refreshJwt = undefined;
179 /** @type {boolean} */
180 this._authenticated = false;
181 }
182
183 /* ── Public API ─────────────────────────────────────────────────── */
184
185 /**
186 * Resolve the handle → DID → PDS endpoint and create an authenticated
187 * session. Must be called (and awaited) before `submit()`.
188 *
189 * Emits an `"authenticated"` event on success.
190 *
191 * @returns {Promise<{ did: string, pdsEndpoint: string }>}
192 * @throws {TealSubmissionError} If resolution or session creation fails.
193 */
194 async init() {
195 if (!this._handle) {
196 throw new TealSubmissionError("Cannot init: no handle configured");
197 }
198 if (!this._appPassword) {
199 throw new TealSubmissionError("Cannot init: no appPassword configured");
200 }
201
202 // If a PDS endpoint was supplied directly, skip DID-based endpoint resolution.
203 // We still resolve the DID for the repo field (or trust createSession to return it).
204 let pdsEndpoint = this._pdsEndpoint;
205 let did = undefined;
206
207 // Step 1: Resolve handle → DID.
208 if (!this._pdsEndpoint || !this._did) {
209 did = await this._resolveDid(this._handle);
210 this._did = did;
211 } else {
212 did = this._did;
213 }
214
215 // Step 2: Resolve DID → PDS endpoint (if not pre-supplied).
216 if (!pdsEndpoint) {
217 pdsEndpoint = await this._resolvePdsEndpoint(did);
218 }
219
220 // Normalise: strip trailing slash.
221 this._pdsEndpoint = pdsEndpoint.replace(/\/+$/, "");
222
223 // Step 3: Create session.
224 await this._createSession();
225
226 this._authenticated = true;
227 this.emit("authenticated", { did: this._did, pdsEndpoint: this._pdsEndpoint });
228 return { did: this._did, pdsEndpoint: this._pdsEndpoint };
229 }
230
231 /**
232 * Submit a play record to the PDS.
233 *
234 * On success, emits a `"submitted"` event and returns `{ uri, cid }`.
235 * On failure, throws a `TealSubmissionError` (subclass) with the
236 * attempted `playData` attached. The caller may catch the error and
237 * re-queue for retry (NUM-15).
238 *
239 * If the access JWT has expired (HTTP 401) and a refresh JWT is
240 * available, the client will attempt to refresh the session and retry
241 * the submission once.
242 *
243 * @param {Object} playData
244 * @param {string} playData.trackName
245 * @param {Array<{ artistName: string, artistMbId?: string }>} [playData.artists]
246 * @param {string} [playData.releaseName]
247 * @param {number} [playData.duration]
248 * @param {string} [playData.playedTime]
249 * @returns {Promise<{ uri: string, cid: string }>}
250 * @throws {TealSubmissionError}
251 */
252 async submit(playData) {
253 if (!this._authenticated || !this._accessJwt) {
254 throw new TealSubmissionError(
255 "TealClient not initialised — call init() first",
256 { playData }
257 );
258 }
259
260 const record = this._buildRecord(playData);
261 const rkey = generateTid();
262
263 try {
264 const result = await this._createRecord(record, rkey);
265 this.emit("submitted", { ...result, playData });
266 return result;
267 } catch (err) {
268 // If the token expired, try refreshing once and retrying.
269 if (err instanceof TealAuthError && this._refreshJwt) {
270 try {
271 await this._refreshSession();
272 const result = await this._createRecord(record, rkey);
273 this.emit("submitted", { ...result, playData });
274 return result;
275 } catch (retryErr) {
276 // Ensure playData is attached.
277 if (retryErr instanceof TealSubmissionError && !retryErr.playData) {
278 retryErr.playData = playData;
279 }
280 this.emit("error", { error: retryErr, playData });
281 throw retryErr;
282 }
283 }
284
285 // Attach playData for retry queueing.
286 if (err instanceof TealSubmissionError && !err.playData) {
287 err.playData = playData;
288 }
289
290 // Emit on the error channel for listeners that don't catch promises.
291 this.emit("error", { error: err, playData });
292 throw err;
293 }
294 }
295
296 /**
297 * Update the client configuration in-place (e.g. when settings change).
298 *
299 * If the handle or appPassword changes, the client is de-authenticated
300 * and `init()` must be called again before submissions will work.
301 * NUM-15 will use this when the user updates PDS credentials.
302 *
303 * @param {Object} config Same shape as the constructor config.
304 */
305 reconfigure(config = {}) {
306 const handleChanged = config.handle !== undefined && config.handle !== this._handle;
307 const passwordChanged =
308 config.appPassword !== undefined && config.appPassword !== this._appPassword;
309
310 if (config.handle !== undefined) this._handle = config.handle;
311 if (config.appPassword !== undefined) this._appPassword = config.appPassword;
312 if (config.musicServiceBaseDomain !== undefined) {
313 this._musicServiceBaseDomain = config.musicServiceBaseDomain;
314 }
315 if (config.userAgent !== undefined) this._userAgent = config.userAgent;
316 if (config.pdsEndpoint !== undefined) this._pdsEndpoint = config.pdsEndpoint;
317 if (config.handleResolver !== undefined) this._handleResolver = config.handleResolver;
318
319 // Force re-auth if credentials changed.
320 if (handleChanged || passwordChanged) {
321 this._authenticated = false;
322 this._accessJwt = undefined;
323 this._refreshJwt = undefined;
324 this._did = undefined;
325 // If only the PDS endpoint changed, keep the DID but force re-auth.
326 this._pdsEndpoint = config.pdsEndpoint || this._pdsEndpoint;
327 }
328 }
329
330 /**
331 * Build a `fm.teal.alpha.feed.play` record from a ProgressTracker
332 * `qualified_play` event.
333 *
334 * This is a **static** helper — no authentication or network access
335 * is required. The caller is responsible for passing the resulting
336 * record (or the relevant fields) to `submit()`.
337 *
338 * @param {Object} zone Roon zone snapshot.
339 * @param {Object} qualifiedPlayData Data from the qualified_play event.
340 * @param {number} qualifiedPlayData.listened_seconds
341 * @param {number} qualifiedPlayData.threshold
342 * @param {number} [qualifiedPlayData.duration]
343 * @returns {{ trackName: string, artists: Array<{artistName: string}>, releaseName: (string|undefined), duration: (number|undefined), playedTime: string, submissionClientAgent: string, musicServiceBaseDomain: string }}
344 */
345 static buildPlayRecord(zone, qualifiedPlayData, musicServiceBaseDomain = "local") {
346 const np = zone && zone.now_playing;
347 const tl = np && np.three_line ? np.three_line : {};
348 const duration = qualifiedPlayData && typeof qualifiedPlayData.duration === "number"
349 ? Math.floor(qualifiedPlayData.duration)
350 : np && typeof np.length === "number"
351 ? Math.floor(np.length)
352 : undefined;
353
354 return {
355 trackName: String(tl.line2 || "Unknown Track"),
356 artists: parseArtists(tl.line1),
357 releaseName: tl.line3 ? String(tl.line3) : undefined,
358 duration: duration,
359 playedTime: new Date().toISOString(),
360 submissionClientAgent: CLIENT_AGENT,
361 musicServiceBaseDomain,
362 };
363 }
364
365 /* ── AT Protocol internals ──────────────────────────────────────── */
366
367 /**
368 * Resolve a Bluesky handle to a DID.
369 *
370 * @param {string} handle - The handle (may include domain suffix).
371 * @returns {Promise<string>} The DID.
372 * @throws {TealSubmissionError}
373 * @private
374 */
375 async _resolveDid(handle) {
376 const url =
377 `${this._handleResolver}/xrpc/com.atproto.identity.resolveHandle` +
378 `?handle=${encodeURIComponent(handle)}`;
379
380 let resp;
381 try {
382 resp = await fetch(url, {
383 method: "GET",
384 headers: { "Accept": "application/json" },
385 });
386 } catch (err) {
387 throw new TealSubmissionError(
388 `Failed to resolve handle "${handle}": ${err.message}`,
389 { playData: null }
390 );
391 }
392
393 if (!resp.ok) {
394 const body = await this._safeJson(resp);
395 throw new TealSubmissionError(
396 `Handle resolution failed for "${handle}" (HTTP ${resp.status})`,
397 { status: resp.status, body }
398 );
399 }
400
401 const data = await resp.json();
402 if (!data.did) {
403 throw new TealSubmissionError(
404 `Handle resolution returned no DID for "${handle}"`
405 );
406 }
407 return data.did;
408 }
409
410 /**
411 * Resolve a DID to its PDS service endpoint URL.
412 *
413 * Supports both `did:plc:` (via PLC directory) and `did:web:`
414 * (via `.well-known/did.json`).
415 *
416 * @param {string} did
417 * @returns {Promise<string>} PDS serviceEndpoint URL.
418 * @throws {TealSubmissionError}
419 * @private
420 */
421 async _resolvePdsEndpoint(did) {
422 let didDocUrl;
423 if (did.startsWith("did:plc:")) {
424 didDocUrl = `${PLC_DIRECTORY}/${encodeURIComponent(did)}`;
425 } else if (did.startsWith("did:web:")) {
426 const domain = did.slice("did:web:".length);
427 didDocUrl = `https://${domain}/.well-known/did.json`;
428 } else {
429 throw new TealSubmissionError(`Unsupported DID method: ${did}`);
430 }
431
432 let resp;
433 try {
434 resp = await fetch(didDocUrl, {
435 method: "GET",
436 headers: { "Accept": "application/json" },
437 });
438 } catch (err) {
439 throw new TealSubmissionError(
440 `Failed to fetch DID document for ${did}: ${err.message}`
441 );
442 }
443
444 if (!resp.ok) {
445 throw new TealSubmissionError(
446 `DID document fetch failed for ${did} (HTTP ${resp.status})`,
447 { status: resp.status }
448 );
449 }
450
451 const doc = await resp.json();
452 const services = Array.isArray(doc.service) ? doc.service : [];
453 const pdsService = services.find(
454 (s) => s && s.id === "#atproto_pds"
455 );
456
457 if (!pdsService || !pdsService.serviceEndpoint) {
458 throw new TealSubmissionError(
459 `No #atproto_pds service endpoint in DID document for ${did}`
460 );
461 }
462
463 return pdsService.serviceEndpoint;
464 }
465
466 /**
467 * Create a new session on the PDS using app-password auth.
468 * Stores the access and refresh JWTs.
469 *
470 * @returns {Promise<void>}
471 * @throws {TealAuthError}
472 * @private
473 */
474 async _createSession() {
475 const url = `${this._pdsEndpoint}/xrpc/com.atproto.server.createSession`;
476
477 let resp;
478 try {
479 resp = await fetch(url, {
480 method: "POST",
481 headers: {
482 "Content-Type": "application/json",
483 "Accept": "application/json",
484 },
485 body: JSON.stringify({
486 identifier: this._handle,
487 password: this._appPassword,
488 }),
489 });
490 } catch (err) {
491 throw new TealSubmissionError(
492 `Failed to create session: ${err.message}`
493 );
494 }
495
496 if (!resp.ok) {
497 const body = await this._safeJson(resp);
498 const msg =
499 body && body.message
500 ? body.message
501 : `Session creation failed (HTTP ${resp.status})`;
502 if (resp.status === 401 || resp.status === 403) {
503 throw new TealAuthError(
504 `Authentication failed: ${msg}. Check your handle and app password.`,
505 { status: resp.status, body }
506 );
507 }
508 throw new TealSubmissionError(msg, { status: resp.status, body });
509 }
510
511 const data = await resp.json();
512 this._accessJwt = data.accessJwt;
513 this._refreshJwt = data.refreshJwt;
514 if (data.did) this._did = data.did;
515 // Some PDSes return the canonical handle; update ours.
516 if (data.handle) this._handle = data.handle;
517 }
518
519 /**
520 * Refresh an expired session using the refresh JWT.
521 *
522 * @returns {Promise<void>}
523 * @private
524 */
525 async _refreshSession() {
526 const url = `${this._pdsEndpoint}/xrpc/com.atproto.server.refreshSession`;
527
528 let resp;
529 try {
530 resp = await fetch(url, {
531 method: "POST",
532 headers: {
533 "Content-Type": "application/json",
534 "Accept": "application/json",
535 "Authorization": `Bearer ${this._refreshJwt}`,
536 },
537 });
538 } catch (err) {
539 throw new TealSubmissionError(
540 `Failed to refresh session: ${err.message}`
541 );
542 }
543
544 if (!resp.ok) {
545 // Refresh failed — force full re-init on next call.
546 this._authenticated = false;
547 this._accessJwt = undefined;
548 this._refreshJwt = undefined;
549 const body = await this._safeJson(resp);
550 throw new TealAuthError(
551 `Session refresh failed (HTTP ${resp.status})`,
552 { status: resp.status, body }
553 );
554 }
555
556 const data = await resp.json();
557 this._accessJwt = data.accessJwt;
558 this._refreshJwt = data.refreshJwt || this._refreshJwt;
559 }
560
561 /**
562 * Create a record on the PDS.
563 *
564 * @param {Object} record - The play record (without collection/repo wrapper).
565 * @param {string} rkey - The TID record key.
566 * @returns {Promise<{ uri: string, cid: string }>}
567 * @throws {TealSubmissionError|TealAuthError|TealRateLimitError}
568 * @private
569 */
570 async _createRecord(record, rkey) {
571 const url = `${this._pdsEndpoint}/xrpc/com.atproto.repo.createRecord`;
572
573 let resp;
574 try {
575 resp = await fetch(url, {
576 method: "POST",
577 headers: {
578 "Content-Type": "application/json",
579 "Accept": "application/json",
580 "Authorization": `Bearer ${this._accessJwt}`,
581 },
582 body: JSON.stringify({
583 repo: this._did,
584 collection: COLLECTION,
585 rkey: rkey,
586 record: record,
587 }),
588 });
589 } catch (err) {
590 throw new TealSubmissionError(
591 `Network error creating record: ${err.message}`
592 );
593 }
594
595 if (!resp.ok) {
596 const body = await this._safeJson(resp);
597
598 if (resp.status === 401 || resp.status === 403) {
599 throw new TealAuthError(
600 `Auth error creating record (HTTP ${resp.status})`,
601 { status: resp.status, body }
602 );
603 }
604
605 if (resp.status === 429) {
606 const retryAfter = resp.headers.get("Retry-After") || undefined;
607 throw new TealRateLimitError(
608 `Rate limited by PDS (HTTP 429)`,
609 { status: 429, body, retryAfter }
610 );
611 }
612
613 const msg =
614 body && body.message
615 ? body.message
616 : `Record creation failed (HTTP ${resp.status})`;
617 throw new TealSubmissionError(msg, {
618 status: resp.status,
619 body,
620 });
621 }
622
623 const data = await resp.json();
624 return { uri: data.uri, cid: data.cid };
625 }
626
627 /* ── Helpers ────────────────────────────────────────────────────── */
628
629 /**
630 * Build the final record object from user-supplied playData, filling
631 * in defaults from client config.
632 *
633 * @param {Object} playData
634 * @returns {Object} Record ready for createRecord.
635 * @private
636 */
637 _buildRecord(playData) {
638 const record = {
639 $type: COLLECTION,
640 trackName: String(playData.trackName || "Unknown Track").slice(0, 256),
641 };
642
643 // Prefer the modern `artists` array; fall back to nothing.
644 if (Array.isArray(playData.artists) && playData.artists.length > 0) {
645 record.artists = playData.artists.map((a) => ({
646 artistName: String(a.artistName).slice(0, 256),
647 ...(a.artistMbId ? { artistMbId: String(a.artistMbId) } : {}),
648 }));
649 }
650
651 if (playData.releaseName != null) {
652 record.releaseName = String(playData.releaseName).slice(0, 256);
653 }
654
655 if (typeof playData.duration === "number" && isFinite(playData.duration)) {
656 record.duration = Math.floor(playData.duration);
657 }
658
659 if (playData.playedTime) {
660 record.playedTime = String(playData.playedTime);
661 } else {
662 record.playedTime = new Date().toISOString();
663 }
664
665 record.submissionClientAgent = this._userAgent;
666 record.musicServiceBaseDomain = this._musicServiceBaseDomain;
667
668 return record;
669 }
670
671 /**
672 * Safely parse a JSON response body, returning null on failure.
673 *
674 * @param {Response} resp
675 * @returns {Promise<*>}
676 * @private
677 */
678 async _safeJson(resp) {
679 try {
680 return await resp.json();
681 } catch (_err) {
682 return null;
683 }
684 }
685
686 /* ── Accessors ──────────────────────────────────────────────────── */
687
688 /**
689 * Whether the client currently holds an active session.
690 * @returns {boolean}
691 */
692 isAuthenticated() {
693 return this._authenticated;
694 }
695
696 /**
697 * The resolved DID (available after `init()`).
698 * @returns {string|undefined}
699 */
700 getDid() {
701 return this._did;
702 }
703
704 /**
705 * The resolved PDS endpoint URL (available after `init()`).
706 * @returns {string|undefined}
707 */
708 getPdsEndpoint() {
709 return this._pdsEndpoint;
710 }
711}
712
713/* ── Module exports ────────────────────────────────────────────────── */
714
715module.exports = {
716 TealClient,
717 TealSubmissionError,
718 TealAuthError,
719 TealRateLimitError,
720 generateTid,
721 parseArtists,
722 CLIENT_AGENT,
723 COLLECTION,
724};