[READ-ONLY] Mirror of https://github.com/scarnecchia/roon-extension-teal-scrobbler. Roon extension that scrobbles qualifying plays to teal.fm via AT Protocol
0

Configure Feed

Select the types of activity you want to include in your feed.

roon-extension-teal-scrobbler / src / teal-client.js
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};