[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.

Initial commit: teal.fm Roon scrobbler extension

Roon extension that scrobbles qualifying plays to a user's PDS as
fm.teal.alpha.feed.play records via AT Protocol. Includes zone
allowlist with grouped-zone dedup, per-zone progress tracking with
scrobble threshold logic, authenticated PDS submission, and a
disk-persisted retry queue with exponential backoff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

author
D. Scarnecchia
co-author
Claude Opus 4.6 (1M context)
date (Jun 25, 2026, 10:13 PM -0400) commit 70b4ab81
+3572
+16
.env.example
··· 1 + # teal.fm Roon Scrobbler — environment configuration 2 + # Copy to .env and fill in your values. 3 + # These seed the initial settings; Roon UI settings override them. 4 + 5 + # Bluesky handle (e.g. yourname.bsky.social) 6 + TEAL_HANDLE= 7 + 8 + # Bluesky app password (generate at Settings → App Passwords) 9 + TEAL_APP_PASSWORD= 10 + 11 + # Music service domain for play records (default: local) 12 + # Examples: local, tidal.com, qobuz.com 13 + TEAL_MUSIC_SERVICE_DOMAIN=local 14 + 15 + # Comma-separated zone IDs to track (empty = all zones) 16 + TEAL_ALLOWED_ZONE_IDS=
+5
.gitignore
··· 1 + node_modules/ 2 + config/ 3 + *.log 4 + .env 5 + .polytoken/
+135
README.md
··· 1 + # teal.fm Scrobbler for Roon 2 + 3 + A Roon extension that scrobbles qualifying plays to your PDS as 4 + `fm.teal.alpha.feed.play` records via the AT Protocol. 5 + 6 + ## How it works 7 + 8 + ``` 9 + ┌────────────┐ subscribe_zones ┌──────────────────────┐ createRecord ┌───────────┐ 10 + │ Roon Core │ ──────────────────▶ │ Roon Extension │ ────────────────▶ │ Your PDS │ 11 + │ (transport)│ zone deltas + │ • zone allowlist │ fm.teal.alpha. │ (Bluesky) │ 12 + └────────────┘ now_playing │ • progress tracker │ feed.play └───────────┘ 13 + │ • teal.fm client │ 14 + │ • retry queue │ 15 + └──────────────────────┘ 16 + (app password auth) 17 + ``` 18 + 19 + 1. **Zone subscription** — Subscribes to Roon's transport service to receive 20 + real-time playback updates for all zones. 21 + 2. **Allowlist filter** — Only tracks zones you select (or all zones if none 22 + selected). Grouped zones are deduplicated so a grouped play counts once. 23 + 3. **Scrobble threshold** — Uses the teal.fm lexicon rule: a play qualifies 24 + when you've listened to the longest of (a) the entire track if under 2 min, 25 + or (b) half the track up to 4 min. 26 + 4. **Submission** — Qualifying plays are submitted to your PDS via 27 + `com.atproto.repo.createRecord` using a Bluesky app password. 28 + 5. **Retry queue** — Failed submissions are persisted to disk and retried with 29 + exponential backoff so you never lose a scrobble during downtime. 30 + 31 + ## Prerequisites 32 + 33 + - [Node.js](https://nodejs.org/) 18+ (tested on Node 25) 34 + - A Roon Core running on your network 35 + - A Bluesky account with an [app password](https://bsky.app/settings/app-passwords) 36 + 37 + ## Installation 38 + 39 + ```bash 40 + git clone <repo-url> 41 + cd teal-fm-roon 42 + npm install 43 + ``` 44 + 45 + ## Running 46 + 47 + ```bash 48 + npm start 49 + # or 50 + node src/index.js 51 + ``` 52 + 53 + The extension will start discovering Roon cores on your network. In Roon, go to 54 + **Settings → Extensions** and enable the **teal.fm Scrobbler**. 55 + 56 + ## Configuration 57 + 58 + All settings are configured through the Roon app under **Settings → Extensions → 59 + teal.fm Scrobbler → Settings**: 60 + 61 + | Setting | Description | 62 + |---|---| 63 + | **Tracked zones** | Comma-separated zone IDs to track. Leave empty to track ALL zones. | 64 + | **Zone slot 1–8** | Pick specific zones from a dropdown (alternative to comma-separated IDs). | 65 + | **Bluesky Handle** | Your Bluesky handle (e.g. `user.bsky.social`). | 66 + | **Bluesky App Password** | An app password from [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords). **Not** your account password. | 67 + | **Music Service Domain** | Base domain of your music service. Defaults to `local`. | 68 + 69 + ### Getting an app password 70 + 71 + 1. Go to <https://bsky.app/settings/app-passwords> 72 + 2. Click **Add App Password** 73 + 3. Name it "Roon Scrobbler" 74 + 4. Copy the generated password into the extension settings 75 + 76 + ## Architecture 77 + 78 + ### Modules 79 + 80 + | File | Responsibility | 81 + |---|---| 82 + | `src/index.js` | Entry point — Roon extension setup, settings, event wiring | 83 + | `src/zone-watcher.js` | Subscribes to Roon transport zones, emits granular events | 84 + | `src/allowlist.js` | Zone filtering + grouped-zone dedup | 85 + | `src/progress-tracker.js` | Per-zone listened-seconds state machine + threshold logic | 86 + | `src/teal-client.js` | AT Protocol submission client (handle → DID → PDS → createRecord) | 87 + | `src/retry-queue.js` | Disk-persisted retry queue with exponential backoff | 88 + 89 + ### Scrobble threshold rule 90 + 91 + A play qualifies for scrobbling when the listener has heard the **longest** of: 92 + 93 + - The entire track (if shorter than 2 minutes), or 94 + - Half the track duration (capped at 4 minutes) 95 + 96 + ``` 97 + threshold = max(min(duration, 120), min(duration / 2, 240)) 98 + ``` 99 + 100 + | Track length | Threshold | 101 + |---|---| 102 + | 90s | 90s (whole track) | 103 + | 200s | 120s (2 min) | 104 + | 600s | 240s (4 min) | 105 + 106 + ### Event pipeline 107 + 108 + ``` 109 + Roon Core 110 + → ZoneWatcher.subscribe_zones() 111 + → ZoneAllowlist.attach(watcher) [filter + dedup] 112 + → ProgressTracker.attach(watcher) [accumulate listened time] 113 + → "qualified_play" event 114 + → TealClient.submit() [createRecord to PDS] 115 + → on failure → RetryQueue [persist + backoff] 116 + ``` 117 + 118 + ## Field mapping (Roon → teal.fm) 119 + 120 + | teal.fm field | Roon source | Notes | 121 + |---|---|---| 122 + | `trackName` | `now_playing.three_line.line2` | Required | 123 + | `artists[].artistName` | `now_playing.three_line.line1` | Parsed on `,`, `&`, `feat.` | 124 + | `releaseName` | `now_playing.three_line.line3` | | 125 + | `duration` | `now_playing.length` | Integer seconds | 126 + | `playedTime` | `new Date().toISOString()` | At threshold crossing | 127 + | `submissionClientAgent` | `fm.teal.roon-scrobbler/0.1.0` | | 128 + | `musicServiceBaseDomain` | Settings (default: `local`) | | 129 + 130 + > **Note:** MbIDs and ISRCs are not available from the Roon transport API and 131 + > are omitted in this MVP. 132 + 133 + ## License 134 + 135 + Apache-2.0
+105
docs/roon-teal-scrobbler.md
··· 1 + # Roon → teal.fm Scrobbler Extension — Brainstorm & Issue Plan 2 + 3 + ## Goal 4 + 5 + Save the complete brainstorm + Linear issue breakdown for a Roon → teal.fm scrobbler extension to 6 + disk as `docs/roon-teal-scrobbler.md` so it persists across a session restart. This is a 7 + documentation/notes file only — no code implementation in this handoff. 8 + 9 + (Background: Linear MCP server is currently `server_unavailable`, so issues could not be created 10 + directly. These notes are the artifact to track until Linear is back.) 11 + 12 + ## Locked design decisions 13 + 14 + - **Auth:** Bluesky **app password** (handle + app password). Matches every existing teal.fm client (teal-cider, malachite, multi-scrobbler). 15 + - **Scrobble threshold:** implement the **lexicon rule** — entire track if <2 min, else half up to 4 min, whichever is longest. Requires a per-zone progress state machine. 16 + - **Zone handling:** **configurable allowlist** of zones to track, with grouped-zone dedup. 17 + - **Linear structure:** one **Epic + sub-issues** per workstream. 18 + 19 + ## Architecture 20 + 21 + ``` 22 + ┌────────────┐ subscribe_zones ┌──────────────────────┐ createRecord ┌───────────┐ 23 + │ Roon Core │ ──────────────────▶ │ Roon Extension │ ────────────────▶ │ Your PDS │ 24 + │ (transport)│ zone deltas + │ • zone allowlist │ fm.teal.alpha. │ (Bluesky) │ 25 + └────────────┘ now_playing │ • progress tracker │ feed.play └───────────┘ 26 + │ • teal.fm client │ 27 + └──────────────────────┘ 28 + (app password auth) 29 + ``` 30 + 31 + - Node.js, `node-roon-api` + `node-roon-api-transport`, HTTP client for `com.atproto.repo.createRecord`. 32 + - Single long-running process. 33 + 34 + ## Field mapping (Roon → teal.fm play record) 35 + 36 + | teal.fm field | Source | Notes | 37 + |---|---|---| 38 + | `trackName` | `now_playing.three_line.line2` | Required | 39 + | `artists[].artistName` | `now_playing.three_line.line1` (artist line) | parsed from display string | 40 + | `releaseName` | `now_playing.three_line.line3` (album line) | | 41 + | `duration` | `now_playing.length` | seconds | 42 + | `playedTime` | wall-clock at threshold crossing | ISO datetime | 43 + | `submissionClientAgent` | constant, e.g. `fm.teal.roon-scrobbler/0.1.0` | | 44 + | `musicServiceBaseDomain` | `local` (or Tidal/Qobuz if detected) | | 45 + | MbIDs / ISRC | — | not surfaced by Roon transport API; omitted in MVP | 46 + 47 + ## Lexicon reference 48 + 49 + - Record: `fm.teal.alpha.feed.play` (`key: tid`). Required: `trackName`. 50 + - `artists[]` items: `fm.teal.alpha.feed.defs#artist` = `{ artistName (required), artistMbId (optional) }`. 51 + - Submitted via `com.atproto.repo.createRecord` to collection `fm.teal.alpha.feed.play`, authenticated with handle + app password. 52 + - Tracked threshold (from lexicon description): entire track if <2 min, or half the track up to 4 min, whichever is longest. 53 + 54 + ## Key references 55 + 56 + - Roon API: `RoonLabs/node-roon-api`, `RoonLabs/node-roon-api-transport`. `subscribe_zones(cb)` → zone state (playing/paused/loading/stopped) + `now_playing` (display lines, `seek_position`, `length`). 57 + - Analog extension: `fjgalesloot/roon-extension-mqtt`. 58 + - Existing teal.fm clients (submission pattern): teal-cider (Go), ewanc26/malachite (TS), FoxxMD/multi-scrobbler (tealfm client). 59 + - Lexicon source: https://raw.githubusercontent.com/teal-fm/teal/refs/heads/main/lexicons/fm.teal.alpha/feed/play.json 60 + 61 + ## Risks / caveats 62 + 63 + - Display-line parsing (artist/album/track from `line1/2/3`) is heuristic — Roon formats these as strings and line assignment can vary by source. Validate against real zone output before assuming field positions. 64 + - MbIDs unavailable from Roon; plays will lack MusicBrainz enrichment. Separate MB-lookup workstream would be out of MVP scope. 65 + - Grouped zones can double-count unless deduped. 66 + 67 + --- 68 + 69 + ## Linear: Epic + sub-issues 70 + 71 + ### Epic 72 + **Roon → teal.fm scrobbler extension** — build a Node Roon extension that scrobbles qualifying plays to a user's PDS as `fm.teal.alpha.feed.play` records. 73 + 74 + ### Issue 1 — Scaffold Roon extension + zone subscription 75 + - `node-roon-api` app skeleton (`extension_id`, discovery, pairing). 76 + - Require `RoonApiTransport`, call `subscribe_zones`, log zone/now_playing deltas. 77 + - Deliverable: running extension that pairs with Roon and prints now-playing. 78 + - No dependencies. 79 + 80 + ### Issue 2 — Configurable zone allowlist 81 + - Settings UI (Roon `RoonApiSettings`) to select which zone(s) to track by id/display name. 82 + - Grouped-zone dedup so grouped playback counts once. 83 + - Depends on #1. 84 + 85 + ### Issue 3 — Per-zone progress tracker + scrobble threshold 86 + - State machine: accumulate listened-seconds from `seek_position` deltas across play/pause/stop/seek transitions. 87 + - Evaluate lexicon rule (whole track if <2min; else half up to 4min; whichever longest). 88 + - Emit one "qualified play" event with dedup keyed on track identity. 89 + - Depends on #1. 90 + 91 + ### Issue 4 — teal.fm submission client 92 + - Authenticated `com.atproto.repo.createRecord` to `fm.teal.alpha.feed.play` using handle + app password. 93 + - Map play → record (field mapping table above); generate TID rkey; PDS resolution. 94 + - Depends on #3 (consumes qualified-play events). 95 + 96 + ### Issue 5 — Settings: credentials + retry queue 97 + - Store teal handle + app password in extension settings (not env). 98 + - On PDS failure, queue plays and retry with backoff so offline periods aren't lost. 99 + - Depends on #4. 100 + 101 + ### Issue 6 — Packaging, README, error handling 102 + - npm package, run instructions, logging. 103 + - Graceful reconnect on core unpair/reconnect. 104 + - Docs for configuring zones + app password. 105 + - Depends on #2, #4, #5.
+73
package-lock.json
··· 1 + { 2 + "name": "roon-teal-scrobbler", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "roon-teal-scrobbler", 9 + "version": "0.1.0", 10 + "license": "Apache-2.0", 11 + "dependencies": { 12 + "node-roon-api": "github:roonlabs/node-roon-api", 13 + "node-roon-api-settings": "github:roonlabs/node-roon-api-settings", 14 + "node-roon-api-status": "github:roonlabs/node-roon-api-status", 15 + "node-roon-api-transport": "github:roonlabs/node-roon-api-transport" 16 + } 17 + }, 18 + "node_modules/node-roon-api": { 19 + "version": "1.2.3", 20 + "resolved": "git+ssh://git@github.com/roonlabs/node-roon-api.git#055dae6c2ac45b6c738aa3b9e5ac1fd722ed60e8", 21 + "license": "Apache-2.0", 22 + "dependencies": { 23 + "node-uuid": "^1.4.7", 24 + "ws": ">=3.3.1" 25 + } 26 + }, 27 + "node_modules/node-roon-api-settings": { 28 + "version": "1.0.0", 29 + "resolved": "git+ssh://git@github.com/roonlabs/node-roon-api-settings.git#67cd8ca156c5bcd01ea63833ceaaec6d6a79654d", 30 + "license": "Apache-2.0" 31 + }, 32 + "node_modules/node-roon-api-status": { 33 + "version": "1.0.0", 34 + "resolved": "git+ssh://git@github.com/roonlabs/node-roon-api-status.git#504c918d6da267e03fbb4337befa71ca3d3c7526", 35 + "license": "Apache-2.0" 36 + }, 37 + "node_modules/node-roon-api-transport": { 38 + "version": "2.0.1", 39 + "resolved": "git+ssh://git@github.com/roonlabs/node-roon-api-transport.git#2ee60008a4cdb90c34ff3de58bb4b949067f1d20", 40 + "license": "Apache-2.0" 41 + }, 42 + "node_modules/node-uuid": { 43 + "version": "1.4.8", 44 + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz", 45 + "integrity": "sha512-TkCET/3rr9mUuRp+CpO7qfgT++aAxfDRaalQhwPFzI9BY/2rCDn6OfpZOVggi1AXfTPpfkTrg5f5WQx5G1uLxA==", 46 + "deprecated": "Use uuid module instead", 47 + "bin": { 48 + "uuid": "bin/uuid" 49 + } 50 + }, 51 + "node_modules/ws": { 52 + "version": "8.21.0", 53 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", 54 + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", 55 + "license": "MIT", 56 + "engines": { 57 + "node": ">=10.0.0" 58 + }, 59 + "peerDependencies": { 60 + "bufferutil": "^4.0.1", 61 + "utf-8-validate": ">=5.0.2" 62 + }, 63 + "peerDependenciesMeta": { 64 + "bufferutil": { 65 + "optional": true 66 + }, 67 + "utf-8-validate": { 68 + "optional": true 69 + } 70 + } 71 + } 72 + } 73 + }
+22
package.json
··· 1 + { 2 + "name": "roon-extension-teal-scrobbler", 3 + "version": "0.1.0", 4 + "description": "Roon extension that scrobbles qualifying plays to teal.fm as fm.teal.alpha.feed.play records", 5 + "main": "src/index.js", 6 + "scripts": { 7 + "start": "node --env-file-if-exists=.env src/index.js", 8 + "test": "node --test test/*.test.js" 9 + }, 10 + "author": "teal.fm", 11 + "license": "Apache-2.0", 12 + "repository": { 13 + "type": "git", 14 + "url": "https://github.com/scarnecchia/roon-extension-teal-scrobbler.git" 15 + }, 16 + "dependencies": { 17 + "node-roon-api": "github:roonlabs/node-roon-api", 18 + "node-roon-api-transport": "github:roonlabs/node-roon-api-transport", 19 + "node-roon-api-settings": "github:roonlabs/node-roon-api-settings", 20 + "node-roon-api-status": "github:roonlabs/node-roon-api-status" 21 + } 22 + }
+478
src/allowlist.js
··· 1 + "use strict"; 2 + 3 + const { EventEmitter } = require("events"); 4 + 5 + /** 6 + * @typedef {Object} RoonZone 7 + * @property {string} zone_id Stable Roon zone identifier. 8 + * @property {string} display_name Human-readable name (may contain " + " for groups). 9 + * @property {string} state Playback state: "playing" | "paused" | "stopped" | "loading" | ... 10 + * @property {Object} [now_playing] Current now-playing payload, if any. 11 + * @property {Object} [now_playing.three_line] 12 + * @property {string} [now_playing.three_line.line1] Artist. 13 + * @property {string} [now_playing.three_line.line2] Track title. 14 + * @property {string} [now_playing.three_line.line3] Album. 15 + */ 16 + 17 + /** 18 + * Number of per-zone picker slots exposed in the Roon settings UI. 19 + * Users can select up to this many zones to track. 20 + * @type {number} 21 + */ 22 + const NUM_ZONE_SLOTS = 8; 23 + 24 + /** 25 + * ZoneAllowlist filters the stream of ZoneWatcher events down to a configurable 26 + * set of zones and de-duplicates plays that originate from grouped Roon zones. 27 + * 28 + * When Roon groups zones together (e.g. "Kitchen + Living Room"), every member 29 + * zone reports the *same* now-playing payload simultaneously. Without dedup a 30 + * single physical play would be counted once per grouped member. ZoneAllowlist 31 + * detects this by keying on the three_line track signature and only forwarding 32 + * the first ("leader") zone for any given track. 33 + * 34 + * Events emitted (after filtering + dedup): 35 + * - "playback_started" { zone } An allowed zone began playing a new/unique track. 36 + * - "playback_stopped" { zone_id } The leader zone for a track stopped or paused. 37 + * 38 + * An empty allowlist means "track all zones". 39 + */ 40 + class ZoneAllowlist extends EventEmitter { 41 + /** 42 + * @param {Object} config 43 + * @param {string[]} [config.allowedZoneIds] Zone IDs to track. Empty = track all. 44 + * @param {string[]} [config.allowedZoneNames] Display names to track (matched case-insensitively, trimmed). 45 + * Combined with allowedZoneIds (union). 46 + */ 47 + constructor(config = {}) { 48 + super(); 49 + 50 + /** @type {Set<string>} lowercased + trimmed display names to allow */ 51 + this._allowedNames = new Set( 52 + (config.allowedZoneNames || []) 53 + .filter(Boolean) 54 + .map((n) => String(n).trim().toLowerCase()) 55 + ); 56 + 57 + /** @type {Set<string>} zone IDs to allow */ 58 + this._allowedIds = new Set( 59 + (config.allowedZoneIds || []) 60 + .filter(Boolean) 61 + .map((id) => String(id)) 62 + ); 63 + 64 + /** 65 + * Whether the allowlist is empty (track-all mode). 66 + * @type {boolean} 67 + */ 68 + this._trackAll = this._allowedIds.size === 0 && this._allowedNames.size === 0; 69 + 70 + /** 71 + * Snapshot of every known zone we've seen from the watcher, keyed by zone_id. 72 + * Used so isAllowed() can resolve a zone_id → display_name even when the 73 + * caller only supplies an ID, and so we can look up secondaries during dedup. 74 + * @type {Map<string, RoonZone>} 75 + */ 76 + this._knownZones = new Map(); 77 + 78 + /** 79 + * Set of zone_ids currently in the "playing" state AND allowed. 80 + * @type {Set<string>} 81 + */ 82 + this._playingZones = new Set(); 83 + 84 + /** 85 + * Map of track signature → leader zone_id for zones currently playing. 86 + * Only the leader emits playback_started / playback_stopped. 87 + * @type {Map<string, string>} 88 + */ 89 + this._trackLeaders = new Map(); 90 + } 91 + 92 + /* ── Public API ─────────────────────────────────────────────────── */ 93 + 94 + /** 95 + * Determine whether a zone should be tracked. 96 + * 97 + * In track-all mode (empty allowlist) every zone is allowed. 98 + * Otherwise a zone is allowed if its zone_id is in the allowlist OR its 99 + * display_name matches (case-insensitive, trimmed) an allowed name. 100 + * 101 + * @param {string} zone_id The zone's stable ID. 102 + * @param {string} [display_name] Optional display name. If omitted, the 103 + * allowlist can only match by ID (or track-all). 104 + * @returns {boolean} 105 + */ 106 + isAllowed(zone_id, display_name) { 107 + if (this._trackAll) return true; 108 + if (!zone_id) return false; 109 + 110 + if (this._allowedIds.has(String(zone_id))) return true; 111 + 112 + if (display_name != null) { 113 + const name = String(display_name).trim().toLowerCase(); 114 + if (this._allowedNames.has(name)) return true; 115 + 116 + // Also match the "first member" of a grouped name (e.g. "Kitchen + 2"). 117 + // If the user allowlisted "Kitchen", a grouped "Kitchen + 2" should count. 118 + const base = _extractBaseName(display_name); 119 + if (base && this._allowedNames.has(base)) return true; 120 + } 121 + 122 + return false; 123 + } 124 + 125 + /** 126 + * Attach to a ZoneWatcher and begin filtering its event stream. 127 + * 128 + * Registers listeners for zone_added, zone_removed, zone_changed, 129 + * state_changed, and track_changed. Returns `this` for chaining. 130 + * The bound listener references are stored on `this._listeners` so a 131 + * future `detach()` could remove them cleanly. 132 + * 133 + * @param {import("./zone-watcher").ZoneWatcher} watcher 134 + * @returns {ZoneAllowlist} 135 + */ 136 + attach(watcher) { 137 + this._listeners = { 138 + zone_added: ({ zone }) => this._onZoneAdded(zone), 139 + zone_removed: ({ zone_id, zone }) => this._onZoneRemoved(zone_id, zone), 140 + zone_changed: ({ zone, prev }) => this._onZoneChanged(zone, prev), 141 + state_changed: ({ zone, prev_state }) => this._onStateChanged(zone, prev_state), 142 + track_changed: ({ zone }) => this._onTrackChanged(zone), 143 + }; 144 + 145 + for (const [evt, fn] of Object.entries(this._listeners)) { 146 + watcher.on(evt, fn); 147 + } 148 + 149 + return this; 150 + } 151 + 152 + /** 153 + * Detach from a previously-attached ZoneWatcher. 154 + * @param {import("./zone-watcher").ZoneWatcher} watcher 155 + */ 156 + detach(watcher) { 157 + if (!this._listeners) return; 158 + for (const [evt, fn] of Object.entries(this._listeners)) { 159 + watcher.off(evt, fn); 160 + } 161 + this._listeners = null; 162 + } 163 + 164 + /** 165 + * Update the allowlist configuration in-place (e.g. when settings change). 166 + * Re-evaluates all known zones against the new allowlist. 167 + * 168 + * @param {Object} config Same shape as the constructor config. 169 + */ 170 + reconfigure(config = {}) { 171 + this._allowedNames = new Set( 172 + (config.allowedZoneNames || []) 173 + .filter(Boolean) 174 + .map((n) => String(n).trim().toLowerCase()) 175 + ); 176 + this._allowedIds = new Set( 177 + (config.allowedZoneIds || []) 178 + .filter(Boolean) 179 + .map((id) => String(id)) 180 + ); 181 + this._trackAll = this._allowedIds.size === 0 && this._allowedNames.size === 0; 182 + 183 + // Re-evaluate playing zones: some may now be disallowed. 184 + for (const zone_id of Array.from(this._playingZones)) { 185 + const zone = this._knownZones.get(zone_id); 186 + if (zone && !this.isAllowed(zone_id, zone.display_name)) { 187 + this._playingZones.delete(zone_id); 188 + this._clearLeadershipForZone(zone_id); 189 + this.emit("playback_stopped", { zone_id }); 190 + } 191 + } 192 + } 193 + 194 + /* ── RoonApiSettings integration ────────────────────────────────── */ 195 + 196 + /** 197 + * Build a RoonApiSettings layout array for selecting tracked zones. 198 + * 199 + * Roon's "zone" setting type renders a dropdown of all current zones. 200 + * We expose `NUM_ZONE_SLOTS` independent slots so the user can pick up to 201 + * that many zones. Each slot's `setting` key is `allow_zone_<n>`. 202 + * 203 + * @param {Object} [currentValues] Current saved settings (setting key → value). 204 + * @returns {Object[]} Layout array suitable for `new RoonApiSettings(...)`. 205 + */ 206 + static getSettingsLayout(currentValues = {}) { 207 + const layout = [ 208 + { 209 + type: "string", 210 + title: "Tracked zones", 211 + setting: "allowed_zone_ids", 212 + description: 213 + "Comma-separated zone IDs to track. Leave empty to track ALL zones.", 214 + }, 215 + ]; 216 + 217 + // Provide explicit per-zone pickers for convenience; values are zone_ids. 218 + for (let i = 1; i <= NUM_ZONE_SLOTS; i++) { 219 + const key = `allow_zone_${i}`; 220 + layout.push({ 221 + type: "zone", 222 + title: `Zone slot ${i}`, 223 + setting: key, 224 + description: i === 1 ? "Pick a zone to track (optional)." : undefined, 225 + }); 226 + } 227 + 228 + return layout; 229 + } 230 + 231 + /** 232 + * Parse raw RoonApiSettings values into an allowlist config. 233 + * 234 + * Accepts both the comma-separated `allowed_zone_ids` string and the 235 + * per-slot `allow_zone_<n>` zone pickers, merging them into a single 236 + * de-duplicated set of zone IDs. An empty result means "track all". 237 + * 238 + * @param {Object} values Raw settings object from the save callback. 239 + * @returns {{ allowedZoneIds: string[], allowedZoneNames: string[] }} 240 + */ 241 + static parseSettings(values = {}) { 242 + const ids = new Set(); 243 + 244 + // Comma-separated string. 245 + const raw = values.allowed_zone_ids; 246 + if (typeof raw === "string") { 247 + for (const part of raw.split(",")) { 248 + const id = part.trim(); 249 + if (id) ids.add(id); 250 + } 251 + } 252 + 253 + // Per-slot zone pickers (value is a zone_id string). 254 + for (let i = 1; i <= NUM_ZONE_SLOTS; i++) { 255 + const v = values[`allow_zone_${i}`]; 256 + if (typeof v === "string" && v.trim()) { 257 + ids.add(v.trim()); 258 + } 259 + } 260 + 261 + return { 262 + allowedZoneIds: Array.from(ids), 263 + allowedZoneNames: [], 264 + }; 265 + } 266 + 267 + /* ── Internal event handlers ────────────────────────────────────── */ 268 + 269 + /** @param {RoonZone} zone */ 270 + _onZoneAdded(zone) { 271 + this._knownZones.set(zone.zone_id, zone); 272 + 273 + // If the zone is already playing when it first appears, treat it as a start. 274 + if (zone.state === "playing" && this.isAllowed(zone.zone_id, zone.display_name)) { 275 + this._handlePlayStart(zone); 276 + } 277 + } 278 + 279 + /** @param {string} zone_id @param {RoonZone} [zone] */ 280 + _onZoneRemoved(zone_id, zone) { 281 + this._knownZones.delete(zone_id); 282 + 283 + if (this._playingZones.has(zone_id)) { 284 + this._handlePlayStop(zone_id); 285 + } 286 + } 287 + 288 + /** 289 + * Coarse-grained zone_changed: ensure our known-zones map stays fresh and 290 + * reconcile play state. Most logic is handled by state_changed/track_changed, 291 + * but this catches cases where state events are missed. 292 + * 293 + * @param {RoonZone} zone 294 + * @param {RoonZone} [prev] 295 + */ 296 + _onZoneChanged(zone, prev) { 297 + this._knownZones.set(zone.zone_id, zone); 298 + } 299 + 300 + /** 301 + * Handle a playback state transition for an allowed zone. 302 + * 303 + * @param {RoonZone} zone 304 + * @param {string} prev_state 305 + */ 306 + _onStateChanged(zone, prev_state) { 307 + this._knownZones.set(zone.zone_id, zone); 308 + 309 + if (!this.isAllowed(zone.zone_id, zone.display_name)) return; 310 + 311 + const nowPlaying = zone.state === "playing"; 312 + const wasPlaying = this._playingZones.has(zone.zone_id); 313 + 314 + if (nowPlaying && !wasPlaying) { 315 + this._handlePlayStart(zone); 316 + } else if (!nowPlaying && wasPlaying) { 317 + this._handlePlayStop(zone.zone_id); 318 + } 319 + } 320 + 321 + /** 322 + * Handle a track change within an already-playing allowed zone. 323 + * This drives a new playback_started (subject to dedup) for the new track. 324 + * 325 + * @param {RoonZone} zone 326 + */ 327 + _onTrackChanged(zone) { 328 + this._knownZones.set(zone.zone_id, zone); 329 + 330 + if (!this.isAllowed(zone.zone_id, zone.display_name)) return; 331 + if (zone.state !== "playing") return; 332 + if (!this._playingZones.has(zone.zone_id)) { 333 + // Not previously tracked as playing — let state_changed handle it. 334 + this._handlePlayStart(zone); 335 + return; 336 + } 337 + 338 + // Already playing; the track identity changed. Re-run dedup for the new track. 339 + // First, clear this zone's leadership of its *previous* track. 340 + this._clearLeadershipForZone(zone.zone_id); 341 + 342 + // Re-enter as if starting fresh for the new track. 343 + this._handlePlayStart(zone); 344 + } 345 + 346 + /* ── Dedup core ─────────────────────────────────────────────────── */ 347 + 348 + /** 349 + * Record a play start for an allowed zone and emit playback_started 350 + * unless another zone is already the leader for the same track signature 351 + * (grouped-zone dedup). 352 + * 353 + * @param {RoonZone} zone 354 + */ 355 + _handlePlayStart(zone) { 356 + this._playingZones.add(zone.zone_id); 357 + 358 + const sig = _trackSignature(zone); 359 + if (!sig) { 360 + // No track identity available — emit without dedup so the play isn't lost. 361 + this.emit("playback_started", { zone }); 362 + return; 363 + } 364 + 365 + const existingLeader = this._trackLeaders.get(sig); 366 + if (existingLeader && existingLeader !== zone.zone_id) { 367 + // Another zone is already the leader for this track → suppress. 368 + // (This zone is a grouped member / duplicate.) 369 + return; 370 + } 371 + 372 + // No leader yet (or we already are) — become the leader and emit. 373 + this._trackLeaders.set(sig, zone.zone_id); 374 + this.emit("playback_started", { zone }); 375 + } 376 + 377 + /** 378 + * Record a play stop for a zone and emit playback_stopped if this zone 379 + * was the leader for its track. If other zones are still playing the 380 + * same track (group members), promote one of them instead of emitting stop. 381 + * 382 + * @param {string} zone_id 383 + */ 384 + _handlePlayStop(zone_id) { 385 + this._playingZones.delete(zone_id); 386 + 387 + const sig = this._clearLeadershipForZone(zone_id); 388 + 389 + if (!sig) { 390 + // We never had a leader entry (or no track identity) — emit stop directly. 391 + this.emit("playback_stopped", { zone_id }); 392 + return; 393 + } 394 + 395 + // Look for another currently-playing, allowed zone with the same signature 396 + // to promote as the new leader (group member taking over). 397 + const promoted = this._findPlayingZoneBySignature(sig, zone_id); 398 + if (promoted) { 399 + this._trackLeaders.set(sig, promoted.zone_id); 400 + // Do NOT emit playback_stopped — playback continues under a new leader. 401 + return; 402 + } 403 + 404 + // No successor — the physical play has ended. 405 + this.emit("playback_stopped", { zone_id }); 406 + } 407 + 408 + /** 409 + * Remove the given zone's leadership entry for its current track, if any. 410 + * Returns the signature that was cleared (or undefined). 411 + * 412 + * @param {string} zone_id 413 + * @returns {string|undefined} 414 + */ 415 + _clearLeadershipForZone(zone_id) { 416 + for (const [sig, leader] of this._trackLeaders) { 417 + if (leader === zone_id) { 418 + this._trackLeaders.delete(sig); 419 + return sig; 420 + } 421 + } 422 + return undefined; 423 + } 424 + 425 + /** 426 + * Find a known, allowed, currently-playing zone (other than `excludeId`) 427 + * whose now_playing matches the given track signature. 428 + * 429 + * @param {string} sig 430 + * @param {string} excludeId 431 + * @returns {RoonZone|undefined} 432 + */ 433 + _findPlayingZoneBySignature(sig, excludeId) { 434 + for (const zone of this._knownZones.values()) { 435 + if (zone.zone_id === excludeId) continue; 436 + if (!this._playingZones.has(zone.zone_id)) continue; 437 + if (!this.isAllowed(zone.zone_id, zone.display_name)) continue; 438 + if (_trackSignature(zone) === sig) return zone; 439 + } 440 + return undefined; 441 + } 442 + } 443 + 444 + /* ── Pure helpers ──────────────────────────────────────────────────── */ 445 + 446 + /** 447 + * Compute a stable signature string for a zone's current track, based on the 448 + * three_line payload (artist / title / album). Returns an empty string when 449 + * the now_playing or three_line data is absent. 450 + * 451 + * @param {RoonZone} zone 452 + * @returns {string} 453 + */ 454 + function _trackSignature(zone) { 455 + const np = zone && zone.now_playing; 456 + const tl = np && np.three_line; 457 + if (!tl) return ""; 458 + return `${tl.line1 || ""}\u0000${tl.line2 || ""}\u0000${tl.line3 || ""}`; 459 + } 460 + 461 + /** 462 + * Extract the "base" member name from a possibly-grouped display name. 463 + * Roon renders groups as "Kitchen + 2" or "Kitchen + Living Room". 464 + * Returns the first member name, lowercased + trimmed, or "" if none. 465 + * 466 + * @param {string} display_name 467 + * @returns {string} 468 + */ 469 + function _extractBaseName(display_name) { 470 + if (!display_name) return ""; 471 + const first = String(display_name).split("+")[0]; 472 + return first.trim().toLowerCase(); 473 + } 474 + 475 + module.exports = { 476 + ZoneAllowlist, 477 + NUM_ZONE_SLOTS, 478 + };
+356
src/index.js
··· 1 + "use strict"; 2 + 3 + const RoonApi = require("node-roon-api"); 4 + const RoonApiStatus = require("node-roon-api-status"); 5 + const RoonApiTransport = require("node-roon-api-transport"); 6 + const RoonApiSettings = require("node-roon-api-settings"); 7 + 8 + const fs = require("fs"); 9 + const path = require("path"); 10 + 11 + const { ZoneWatcher } = require("./zone-watcher"); 12 + const { ZoneAllowlist } = require("./allowlist"); 13 + const { ProgressTracker } = require("./progress-tracker"); 14 + const { TealClient } = require("./teal-client"); 15 + const { RetryQueue } = require("./retry-queue"); 16 + 17 + /** 18 + * teal.fm Scrobbler — Roon Extension 19 + * 20 + * Scrobbles qualifying plays to a user's PDS as fm.teal.alpha.feed.play records. 21 + * 22 + * Architecture: 23 + * Roon Core → ZoneWatcher → { ZoneAllowlist (filter), ProgressTracker (threshold) } 24 + * → qualified_play → teal client (NUM-14+) 25 + */ 26 + 27 + const EXTENSION_ID = "fm.teal.roon-scrobbler"; 28 + const DISPLAY_NAME = "teal.fm Scrobbler"; 29 + const DISPLAY_VERSION = "0.1.0"; 30 + 31 + // ── Core modules ──────────────────────────────────────────────────── 32 + 33 + const watcher = new ZoneWatcher(); 34 + const envZoneIds = (process.env.TEAL_ALLOWED_ZONE_IDS || "") 35 + .split(",") 36 + .map((s) => s.trim()) 37 + .filter(Boolean); 38 + const allowlist = new ZoneAllowlist({ allowedZoneIds: envZoneIds }); 39 + const tracker = new ProgressTracker(); 40 + 41 + // teal.fm submission client (configured with credentials from settings). 42 + let tealClient = null; 43 + let tealClientReady = false; 44 + 45 + // Retry queue with disk persistence so offline plays aren't lost. 46 + const retryQueue = new RetryQueue({ 47 + persistPath: path.join(__dirname, "..", "config", "retry-queue.json"), 48 + }); 49 + retryQueue.on("retried", ({ result }) => { 50 + console.log(`[retry] Play submitted on retry: ${result.uri}`); 51 + }); 52 + retryQueue.on("dropped", ({ playData }) => { 53 + console.warn(`[retry] Play permanently dropped: ${playData.trackName}`); 54 + }); 55 + retryQueue.on("drained", () => { 56 + console.log("[retry] Queue drained — all plays submitted"); 57 + }); 58 + 59 + // ── Settings ──────────────────────────────────────────────────────── 60 + 61 + let _settings = { 62 + allowed_zone_ids: process.env.TEAL_ALLOWED_ZONE_IDS || "", 63 + teal_handle: process.env.TEAL_HANDLE || "", 64 + teal_app_password: process.env.TEAL_APP_PASSWORD || "", 65 + music_service_domain: process.env.TEAL_MUSIC_SERVICE_DOMAIN || "local", 66 + }; 67 + 68 + // ── Roon extension setup ──────────────────────────────────────────── 69 + 70 + const roon = new RoonApi({ 71 + extension_id: EXTENSION_ID, 72 + display_name: DISPLAY_NAME, 73 + display_version: DISPLAY_VERSION, 74 + publisher: "teal.fm", 75 + email: "contact@teal.fm", 76 + website: "https://teal.fm", 77 + 78 + core_paired(core) { 79 + console.log( 80 + `[core] Paired: ${core.display_name} ` + 81 + `(v${core.display_version}, id ${core.core_id})` 82 + ); 83 + watcher.subscribe(core); 84 + }, 85 + 86 + core_unpaired(core) { 87 + console.log( 88 + `[core] Unpaired: ${core.display_name} (id ${core.core_id})` 89 + ); 90 + }, 91 + }); 92 + 93 + function buildSettingsLayout() { 94 + const layout = ZoneAllowlist.getSettingsLayout(_settings); 95 + layout.push( 96 + { 97 + type: "string", 98 + title: "Bluesky Handle", 99 + setting: "teal_handle", 100 + }, 101 + { 102 + type: "string", 103 + title: "Bluesky App Password", 104 + setting: "teal_app_password", 105 + }, 106 + { 107 + type: "string", 108 + title: "Music Service Domain", 109 + setting: "music_service_domain", 110 + } 111 + ); 112 + return layout; 113 + } 114 + 115 + const svc_settings = new RoonApiSettings(roon, { 116 + get_settings: function (cb) { 117 + cb({ 118 + values: _settings, 119 + layout: buildSettingsLayout(), 120 + }); 121 + }, 122 + save_settings: function (req, isdryrun, ignored) { 123 + if (req.body.settings && req.body.settings.values) { 124 + _settings = req.body.settings.values; 125 + } 126 + req.send_complete("Success", { 127 + settings: { 128 + values: _settings, 129 + layout: buildSettingsLayout(), 130 + }, 131 + }); 132 + 133 + if (!isdryrun) { 134 + const config = ZoneAllowlist.parseSettings(_settings); 135 + allowlist.reconfigure(config); 136 + console.log( 137 + `[settings] Allowlist updated: ` + 138 + (config.allowedZoneIds.length === 0 139 + ? "tracking ALL zones" 140 + : `${config.allowedZoneIds.length} zone(s)`) 141 + ); 142 + 143 + // Configure teal.fm client if credentials are present. 144 + if (_settings.teal_handle && _settings.teal_app_password) { 145 + configureTealClient(); 146 + } 147 + } 148 + }, 149 + }); 150 + 151 + const svc_status = new RoonApiStatus(roon); 152 + 153 + roon.init_services({ 154 + required_services: [RoonApiTransport], 155 + provided_services: [svc_status, svc_settings], 156 + }); 157 + 158 + svc_status.set_status("teal.fm scrobbler running", false); 159 + 160 + // ── Wire up event pipeline ────────────────────────────────────────── 161 + 162 + // Allowlist filters zone events and handles grouped-zone dedup. 163 + allowlist.attach(watcher); 164 + 165 + // ProgressTracker accumulates listened time and emits qualified_play. 166 + tracker.attach(watcher); 167 + 168 + // Zone events (NUM-11 deliverable — logging) 169 + watcher.on("subscribed", ({ zones }) => { 170 + console.log(`[zones] Subscribed — ${zones.length} zone(s) active`); 171 + }); 172 + 173 + watcher.on("state_changed", ({ zone, prev_state }) => { 174 + const np = zone.now_playing; 175 + const title = np && np.three_line ? np.three_line.line2 : "?"; 176 + console.log( 177 + `[state] ${zone.display_name}: ${prev_state} → ${zone.state} « ${title} »` 178 + ); 179 + }); 180 + 181 + watcher.on("track_changed", ({ zone, prev_now_playing }) => { 182 + const np = zone.now_playing; 183 + const curTitle = np && np.three_line ? np.three_line.line2 : "?"; 184 + const curArtist = np && np.three_line ? np.three_line.line1 : "?"; 185 + const curAlbum = np && np.three_line ? np.three_line.line3 : "?"; 186 + const prevTitle = prev_now_playing && prev_now_playing.three_line 187 + ? prev_now_playing.three_line.line2 188 + : "?"; 189 + 190 + console.log( 191 + `[track] ${zone.display_name}: « ${prevTitle} » → « ${curTitle} » ` + 192 + `by ${curArtist} from ${curAlbum}` 193 + ); 194 + if (np) { 195 + console.log( 196 + ` length: ${np.length ?? "?"}s seek: ${np.seek_position ?? "?"}s` 197 + ); 198 + } 199 + }); 200 + 201 + // Allowlist events 202 + allowlist.on("playback_started", ({ zone }) => { 203 + console.log(`[allowlist] Playback started in ${zone.display_name}`); 204 + }); 205 + 206 + allowlist.on("playback_stopped", ({ zone_id }) => { 207 + console.log(`[allowlist] Playback stopped (${zone_id})`); 208 + }); 209 + 210 + // ProgressTracker events 211 + tracker.on("track_started", ({ zone_id, track }) => { 212 + console.log( 213 + `[tracker] Track started in ${zone_id}: ${track ? track.line2 : "?"}` 214 + ); 215 + }); 216 + 217 + tracker.on("track_reset", ({ zone_id, track, listened_seconds }) => { 218 + console.log( 219 + `[tracker] Track reset in ${zone_id}: ${track ? track.line2 : "?"} ` + 220 + `(${listened_seconds.toFixed(1)}s listened — did not qualify)` 221 + ); 222 + }); 223 + 224 + // Qualified play — forward to the teal.fm submission client. 225 + tracker.on("qualified_play", ({ zone_id, zone, listened_seconds, threshold, duration }) => { 226 + const np = zone && zone.now_playing; 227 + const title = np && np.three_line ? np.three_line.line2 : "?"; 228 + const artist = np && np.three_line ? np.three_line.line1 : "?"; 229 + 230 + // Only act on zones the allowlist permits. 231 + if (!allowlist.isAllowed(zone_id, zone && zone.display_name)) { 232 + return; 233 + } 234 + 235 + console.log( 236 + `[scrobble] QUALIFIED PLAY: « ${title} » by ${artist} — ` + 237 + `${listened_seconds.toFixed(1)}s / ${threshold}s threshold ` + 238 + `(duration: ${duration ?? "?"}s) in ${zone.display_name}` 239 + ); 240 + 241 + if (!tealClient) { 242 + console.log("[scrobble] Skipping — teal.fm client not configured"); 243 + return; 244 + } 245 + 246 + const playRecord = TealClient.buildPlayRecord(zone, { duration }, _settings.music_service_domain); 247 + 248 + if (!tealClientReady) { 249 + console.log("[scrobble] Client not ready — queuing play"); 250 + retryQueue.enqueue(playRecord); 251 + return; 252 + } 253 + 254 + tealClient.submit(playRecord).catch((err) => { 255 + console.error(`[scrobble] Submission failed: ${err.message} — queuing for retry`); 256 + retryQueue.enqueue(playRecord); 257 + }); 258 + }); 259 + 260 + watcher.on("unsubscribed", () => { 261 + console.log("[zones] Subscription lost"); 262 + }); 263 + 264 + // ── Teal client management ────────────────────────────────────────── 265 + 266 + let _configuring = false; 267 + 268 + async function configureTealClient() { 269 + if (_configuring) return; 270 + _configuring = true; 271 + const handle = _settings.teal_handle; 272 + const appPassword = _settings.teal_app_password; 273 + const musicServiceBaseDomain = _settings.music_service_domain || "local"; 274 + 275 + if (!handle || !appPassword) { 276 + console.log("[teal] No credentials configured — submissions disabled"); 277 + tealClientReady = false; 278 + return; 279 + } 280 + 281 + const config = { handle, appPassword, musicServiceBaseDomain }; 282 + 283 + if (tealClient) { 284 + tealClient.reconfigure(config); 285 + } else { 286 + tealClient = new TealClient(config); 287 + tealClient.on("authenticated", ({ did, pdsEndpoint }) => { 288 + console.log(`[teal] Authenticated as ${did} via ${pdsEndpoint}`); 289 + }); 290 + tealClient.on("submitted", ({ uri }) => { 291 + console.log(`[teal] Play submitted: ${uri}`); 292 + }); 293 + tealClient.on("error", ({ error, playData }) => { 294 + console.error(`[teal] Submission error: ${error.message}`); 295 + }); 296 + } 297 + 298 + tealClientReady = false; 299 + try { 300 + await tealClient.init(); 301 + tealClientReady = true; 302 + console.log("[teal] Client ready — submissions enabled"); 303 + 304 + retryQueue.setSubmitFn((playData) => tealClient.submit(playData)); 305 + retryQueue.start(); 306 + if (retryQueue.size > 0) { 307 + console.log(`[teal] Flushing ${retryQueue.size} queued play(s)`); 308 + retryQueue.flush(); 309 + } 310 + } catch (err) { 311 + console.error(`[teal] Authentication failed: ${err.message}`); 312 + console.error("[teal] Submissions disabled — check credentials in settings"); 313 + } finally { 314 + _configuring = false; 315 + } 316 + } 317 + 318 + // ── Start ─────────────────────────────────────────────────────────── 319 + 320 + roon.start_discovery(); 321 + 322 + console.log(`${DISPLAY_NAME} v${DISPLAY_VERSION} — discovering Roon cores…`); 323 + 324 + if (_settings.teal_handle && _settings.teal_app_password) { 325 + configureTealClient(); 326 + } 327 + 328 + // ── Graceful shutdown ─────────────────────────────────────────────── 329 + 330 + function shutdown(signal) { 331 + console.log(`\n[shutdown] Received ${signal} — cleaning up…`); 332 + 333 + retryQueue.stop(); 334 + console.log(`[shutdown] Retry queue stopped (${retryQueue.size} play(s) persisted)`); 335 + 336 + if (tealClient) { 337 + console.log("[shutdown] Teal client closed"); 338 + } 339 + 340 + console.log("[shutdown] Goodbye"); 341 + process.exit(0); 342 + } 343 + 344 + process.on("SIGINT", () => shutdown("SIGINT")); 345 + process.on("SIGTERM", () => shutdown("SIGTERM")); 346 + 347 + // Catch uncaught errors so a single bad event doesn't crash the extension. 348 + process.on("uncaughtException", (err) => { 349 + console.error("[fatal] Uncaught exception:", err.message); 350 + console.error(err.stack); 351 + }); 352 + process.on("unhandledRejection", (reason) => { 353 + console.error("[fatal] Unhandled rejection:", reason); 354 + }); 355 + 356 + module.exports = { roon, watcher, allowlist, tracker };
+435
src/progress-tracker.js
··· 1 + "use strict"; 2 + 3 + const { EventEmitter } = require("events"); 4 + 5 + /* ── Tunable constants ──────────────────────────────────────────────── */ 6 + 7 + /** 8 + * How far (in seconds) the observed seek_position may diverge from the 9 + * wall-clock-predicted position before we treat it as a user-initiated 10 + * seek rather than normal playback drift. 11 + */ 12 + const SEEK_TOLERANCE_SECONDS = 5; 13 + 14 + /** 15 + * Upper bound on a single accumulation interval. If the wall-clock gap 16 + * between two updates exceeds this (e.g. laptop sleep, stalled network), 17 + * we refuse to credit it as listening time. Prevents phantom inflation. 18 + */ 19 + const STALE_WINDOW_SECONDS = 10; 20 + 21 + /** 22 + * Default threshold (seconds) used when a track has no known duration. 23 + * Matches the "short track" cap from the teal.fm scrobble rule. 24 + */ 25 + const DEFAULT_THRESHOLD_SECONDS = 120; 26 + 27 + const SHORT_TRACK_CAP_SECONDS = 120; // "whole track" branch cap (2 min) 28 + const HALF_TRACK_CAP_SECONDS = 240; // "half track" branch cap (4 min) 29 + 30 + /* ── Core threshold logic ───────────────────────────────────────────── */ 31 + 32 + /** 33 + * Compute the scrobble threshold (in seconds) for a track of the given 34 + * duration, following the teal.fm lexicon rule: 35 + * 36 + * threshold = max( min(duration, 120), min(duration / 2, 240) ) 37 + * 38 + * i.e. a play qualifies once the listener has heard the LONGEST of: 39 + * - the entire track (for tracks shorter than 2 minutes), or 40 + * - half the track (capped at 4 minutes). 41 + * 42 + * Examples: 43 + * - 90s track → max(90, 45) = 90s (listen to the whole thing) 44 + * - 200s track → max(120, 100) = 120s (listen to 2 min) 45 + * - 600s track → max(120, 240) = 240s (listen to 4 min) 46 + * 47 + * @param {number|undefined} duration - Track length in seconds. May be 48 + * undefined / non-finite / non-positive for streams with no duration. 49 + * @returns {number} Threshold in seconds (always a positive finite number). 50 + */ 51 + function computeThreshold(duration) { 52 + if (typeof duration !== "number" || !isFinite(duration) || duration <= 0) { 53 + return DEFAULT_THRESHOLD_SECONDS; 54 + } 55 + const wholeBranch = Math.min(duration, SHORT_TRACK_CAP_SECONDS); 56 + const halfBranch = Math.min(duration / 2, HALF_TRACK_CAP_SECONDS); 57 + return Math.max(wholeBranch, halfBranch); 58 + } 59 + 60 + /* ── ProgressTracker ────────────────────────────────────────────────── */ 61 + 62 + /** 63 + * Per-zone state machine that accumulates "listened seconds" for the 64 + * currently-playing track in each Roon zone and emits a `qualified_play` 65 + * event once the scrobble threshold has been reached. 66 + * 67 + * Time-accumulation model 68 + * ----------------------- 69 + * Roon delivers periodic `seek` updates containing the current playback 70 + * position (`seek_position`) and track `length`. Between two consecutive 71 + * updates we know how much wall-clock time elapsed. While a zone is 72 + * `playing` we credit that wall-clock interval as listening time, with 73 + * two corrections for user seeks: 74 + * 75 + * - Forward seek (position jumps far ahead of prediction): only the 76 + * wall-clock interval is credited — the skipped range is NOT counted 77 + * as listened. 78 + * - Backward seek (position jumps behind prediction): the wall-clock 79 + * interval is credited, then the accumulator is reduced by the size 80 + * of the rewind (floored at 0), reflecting that the listener moved 81 + * away from content they had been approaching. 82 + * 83 + * This yields an accurate, monotonic-ish estimate of genuine listening 84 + * without ever counting skipped content. 85 + * 86 + * Events emitted: 87 + * - "qualified_play" { zone_id, zone, listened_seconds, threshold, duration } 88 + * Emitted at most once per track when listened_seconds >= threshold. 89 + * - "track_started" { zone_id, track } 90 + * A new track began playing in the zone. 91 + * - "track_reset" { zone_id, track, listened_seconds } 92 + * A track ended or was stopped before reaching the threshold. 93 + */ 94 + class ProgressTracker extends EventEmitter { 95 + constructor() { 96 + super(); 97 + /** @type {Map<string, object>} zone_id → per-zone state */ 98 + this._zones = new Map(); 99 + /** Bound listener references so we can detach cleanly. */ 100 + this._listeners = null; 101 + } 102 + 103 + /* ── Public API ────────────────────────────────────────────────── */ 104 + 105 + /** 106 + * Subscribe to a ZoneWatcher's events. 107 + * @param {import("./zone-watcher").ZoneWatcher} watcher 108 + */ 109 + attach(watcher) { 110 + // Detach first to avoid double-binding if attach() is called twice. 111 + if (this._listeners) this.detach(watcher); 112 + 113 + const onZoneAdded = ({ zone }) => this._onZoneAdded(zone); 114 + const onZoneRemoved = ({ zone_id }) => this._zones.delete(zone_id); 115 + const onStateChanged = ({ zone, prev_state }) => 116 + this._onStateChanged(zone, prev_state); 117 + const onTrackChanged = ({ zone, prev_now_playing }) => 118 + this._onTrackChanged(zone, prev_now_playing); 119 + const onSeek = ({ zone_id, seek_position, length }) => 120 + this._onSeek(zone_id, seek_position, length); 121 + 122 + this._listeners = { 123 + zone_added: onZoneAdded, 124 + zone_removed: onZoneRemoved, 125 + state_changed: onStateChanged, 126 + track_changed: onTrackChanged, 127 + seek: onSeek, 128 + }; 129 + 130 + watcher.on("zone_added", onZoneAdded); 131 + watcher.on("zone_removed", onZoneRemoved); 132 + watcher.on("state_changed", onStateChanged); 133 + watcher.on("track_changed", onTrackChanged); 134 + watcher.on("seek", onSeek); 135 + 136 + this._watcher = watcher; 137 + } 138 + 139 + /** 140 + * Unsubscribe from a previously-attached ZoneWatcher. 141 + * @param {import("./zone-watcher").ZoneWatcher} watcher 142 + */ 143 + detach(watcher) { 144 + if (!this._listeners) return; 145 + const w = watcher || this._watcher; 146 + if (w) { 147 + w.off("zone_added", this._listeners.zone_added); 148 + w.off("zone_removed", this._listeners.zone_removed); 149 + w.off("state_changed", this._listeners.state_changed); 150 + w.off("track_changed", this._listeners.track_changed); 151 + w.off("seek", this._listeners.seek); 152 + } 153 + this._listeners = null; 154 + } 155 + 156 + /** 157 + * Get a debug snapshot of accumulated progress for a zone. Flushes any 158 + * in-flight playing interval first so the value is current. 159 + * @param {string} zone_id 160 + * @returns {object|undefined} Progress info, or undefined if the zone 161 + * is unknown to the tracker. 162 + */ 163 + getProgress(zone_id) { 164 + const st = this._zones.get(zone_id); 165 + if (!st) return undefined; 166 + this._flush(st); 167 + return { 168 + zone_id, 169 + track: st.track, 170 + duration: st.duration, 171 + threshold: st.threshold, 172 + listened_seconds: st.listened, 173 + is_playing: st.is_playing, 174 + scrobbled: st.scrobbled, 175 + }; 176 + } 177 + 178 + /* ── Event handlers ────────────────────────────────────────────── */ 179 + 180 + /** 181 + * A zone appeared (either at subscription time or later). Seed its 182 + * state so an already-playing track is tracked immediately. 183 + */ 184 + _onZoneAdded(zone) { 185 + const st = this._newState(zone.zone_id); 186 + st.zone = zone; 187 + st.state = zone.state || null; 188 + this._zones.set(zone.zone_id, st); 189 + 190 + const np = zone.now_playing; 191 + if (np) { 192 + this._initTrack(st, np); 193 + st.is_playing = zone.state === "playing"; 194 + st.position = _seekOf(np); 195 + st.last_ts = st.is_playing ? Date.now() : null; 196 + if (st.track) { 197 + this.emit("track_started", { zone_id: zone.zone_id, track: st.track }); 198 + } 199 + } 200 + } 201 + 202 + /** Playback state transition (playing / paused / stopped / loading). */ 203 + _onStateChanged(zone, _prev_state) { 204 + const st = this._ensureZone(zone); 205 + st.zone = zone; 206 + st.state = zone.state || st.state; 207 + 208 + // Keep duration/threshold fresh in case length arrived late. 209 + if (zone.now_playing) { 210 + this._refreshDuration(st, zone.now_playing); 211 + if (!st.track) this._initTrack(st, zone.now_playing); 212 + } 213 + 214 + const playing = zone.state === "playing"; 215 + 216 + if (playing && !st.is_playing) { 217 + // (Re)starting playback — begin a fresh accumulation interval. 218 + st.is_playing = true; 219 + st.last_ts = Date.now(); 220 + st.position = _seekOf(zone.now_playing); 221 + } else if (!playing && st.is_playing) { 222 + // Leaving "playing" — credit the final segment and pause. 223 + this._flush(st); 224 + st.is_playing = false; 225 + } 226 + 227 + // A transition to "stopped" ends the current play attempt. We keep 228 + // the per-zone state (so a resume of the SAME track cannot 229 + // re-scrobble) but emit track_reset if it never qualified. 230 + if (zone.state === "stopped" && st.track && !st.scrobbled) { 231 + this.emit("track_reset", { 232 + zone_id: zone.zone_id, 233 + track: st.track, 234 + listened_seconds: st.listened, 235 + }); 236 + } 237 + 238 + this._checkQualified(zone.zone_id, st); 239 + } 240 + 241 + /** A new track started in a zone. */ 242 + _onTrackChanged(zone, _prev_now_playing) { 243 + const st = this._ensureZone(zone); 244 + st.zone = zone; 245 + st.state = zone.state || st.state; 246 + 247 + // Finalize the outgoing track. 248 + if (st.track) { 249 + this._flush(st); 250 + if (!st.scrobbled) { 251 + this.emit("track_reset", { 252 + zone_id: zone.zone_id, 253 + track: st.track, 254 + listened_seconds: st.listened, 255 + }); 256 + } 257 + } 258 + 259 + // Seed the incoming track. 260 + const np = zone.now_playing; 261 + this._initTrack(st, np || {}); 262 + st.is_playing = zone.state === "playing"; 263 + st.position = _seekOf(np); 264 + st.last_ts = st.is_playing ? Date.now() : null; 265 + 266 + if (st.track) { 267 + this.emit("track_started", { zone_id: zone.zone_id, track: st.track }); 268 + } 269 + this._checkQualified(zone.zone_id, st); 270 + } 271 + 272 + /** Periodic seek-position update from Roon. */ 273 + _onSeek(zone_id, seek_position, length) { 274 + const st = this._zones.get(zone_id); 275 + if (!st) return; // ignore seeks for zones we don't know about 276 + 277 + if (typeof length === "number" && isFinite(length) && length > 0) { 278 + st.duration = length; 279 + st.threshold = computeThreshold(length); 280 + } 281 + 282 + if ( 283 + st.is_playing && 284 + st.last_ts != null && 285 + typeof seek_position === "number" && 286 + typeof st.position === "number" 287 + ) { 288 + const now = Date.now(); 289 + const dtWall = (now - st.last_ts) / 1000; 290 + 291 + if (dtWall > 0 && dtWall <= STALE_WINDOW_SECONDS) { 292 + const expected = st.position + dtWall; 293 + const diff = seek_position - expected; // signed 294 + 295 + if (diff > SEEK_TOLERANCE_SECONDS) { 296 + // Forward seek: credit only genuine wall-clock listening, 297 + // never the skipped range. 298 + st.listened += dtWall; 299 + } else if (diff < -SEEK_TOLERANCE_SECONDS) { 300 + // Backward seek: credit the wall-clock interval, then 301 + // subtract the rewind magnitude (floor at 0). 302 + const rewind = -diff; 303 + st.listened = Math.max(0, st.listened + dtWall - rewind); 304 + } else { 305 + // Normal continuous playback. 306 + st.listened += dtWall; 307 + } 308 + } 309 + // If dtWall exceeds STALE_WINDOW we skip crediting (likely a 310 + // sleep/stall) and just re-anchor on this update. 311 + } 312 + 313 + if (typeof seek_position === "number") { 314 + st.position = seek_position; 315 + } 316 + st.last_ts = Date.now(); 317 + 318 + this._checkQualified(zone_id, st); 319 + } 320 + 321 + /* ── Internals ─────────────────────────────────────────────────── */ 322 + 323 + /** 324 + * Credit any in-flight playing interval up to "now" and re-check the 325 + * threshold. Used by getProgress and state transitions. 326 + * @param {object} st - per-zone state 327 + */ 328 + _flush(st) { 329 + if (!st.is_playing || st.last_ts == null) return; 330 + const now = Date.now(); 331 + const dtWall = (now - st.last_ts) / 1000; 332 + if (dtWall > 0 && dtWall <= STALE_WINDOW_SECONDS) { 333 + st.listened += dtWall; 334 + } 335 + st.last_ts = now; 336 + this._checkQualified(st.zone_id, st); 337 + } 338 + 339 + /** 340 + * Emit `qualified_play` exactly once per track when the threshold is met. 341 + */ 342 + _checkQualified(zone_id, st) { 343 + if (st.scrobbled) return; 344 + if (st.listened >= st.threshold) { 345 + st.scrobbled = true; 346 + this.emit("qualified_play", { 347 + zone_id, 348 + zone: st.zone, 349 + listened_seconds: st.listened, 350 + threshold: st.threshold, 351 + duration: st.duration, 352 + }); 353 + } 354 + } 355 + 356 + /** Ensure a zone-state object exists (creating if necessary). */ 357 + _ensureZone(zone) { 358 + let st = this._zones.get(zone.zone_id); 359 + if (!st) { 360 + st = this._newState(zone.zone_id); 361 + st.zone = zone; 362 + this._zones.set(zone.zone_id, st); 363 + } 364 + return st; 365 + } 366 + 367 + /** Construct a fresh per-zone state object. */ 368 + _newState(zone_id) { 369 + return { 370 + zone_id, 371 + zone: null, 372 + track: null, // { line1, line2, line3 } identity 373 + duration: undefined, // seconds 374 + threshold: DEFAULT_THRESHOLD_SECONDS, 375 + listened: 0, // accumulated seconds 376 + position: 0, // last seek_position basis 377 + last_ts: null, // ms timestamp of last update while playing 378 + is_playing: false, 379 + state: null, // playing | paused | stopped | loading 380 + scrobbled: false, // dedup flag 381 + }; 382 + } 383 + 384 + /** 385 + * Seed track identity + duration + threshold for a freshly-seen track. 386 + * Resets accumulation and the dedup flag. 387 + */ 388 + _initTrack(st, nowPlaying) { 389 + const np = nowPlaying || {}; 390 + const tl = np.three_line 391 + ? { 392 + line1: np.three_line.line1, 393 + line2: np.three_line.line2, 394 + line3: np.three_line.line3, 395 + } 396 + : null; 397 + 398 + st.track = tl; 399 + st.duration = 400 + typeof np.length === "number" && np.length > 0 ? np.length : undefined; 401 + st.threshold = computeThreshold(st.duration); 402 + st.listened = 0; 403 + st.scrobbled = false; 404 + st.position = _seekOf(np); 405 + } 406 + 407 + /** Update duration/threshold if a length value just became known. */ 408 + _refreshDuration(st, nowPlaying) { 409 + if ( 410 + typeof nowPlaying.length === "number" && 411 + isFinite(nowPlaying.length) && 412 + nowPlaying.length > 0 && 413 + nowPlaying.length !== st.duration 414 + ) { 415 + st.duration = nowPlaying.length; 416 + st.threshold = computeThreshold(nowPlaying.length); 417 + } 418 + } 419 + } 420 + 421 + /* ── Helpers ────────────────────────────────────────────────────────── */ 422 + 423 + /** 424 + * Safely extract seek_position from a now_playing object. 425 + * @param {object|undefined} np 426 + * @returns {number} seek_position in seconds, or 0 if unknown. 427 + */ 428 + function _seekOf(np) { 429 + if (np && typeof np.seek_position === "number" && isFinite(np.seek_position)) { 430 + return np.seek_position; 431 + } 432 + return 0; 433 + } 434 + 435 + module.exports = { ProgressTracker, computeThreshold };
+295
src/retry-queue.js
··· 1 + "use strict"; 2 + 3 + const { EventEmitter } = require("events"); 4 + const fs = require("fs"); 5 + const path = require("path"); 6 + 7 + /** 8 + * RetryQueue buffers play submissions that failed (network error, auth 9 + * failure, rate limit) and retries them with exponential backoff. 10 + * 11 + * Plays are persisted to a JSON file on disk so they survive extension 12 + * restarts — a user whose PDS was temporarily unreachable will not lose 13 + * scrobbles. 14 + * 15 + * Events: 16 + * - "retried" { playData, result } A queued play was successfully submitted. 17 + * - "failed" { playData, error } A retry attempt failed; play remains queued. 18 + * - "dropped" { playData } A play was dropped after max attempts. 19 + * - "drained" The queue is empty (all plays submitted). 20 + * - "enqueue" { playData, queueSize } A play was added to the queue. 21 + */ 22 + 23 + /** 24 + * Default backoff configuration (exponential with jitter). 25 + */ 26 + const DEFAULTS = { 27 + maxAttempts: 10, // total attempts per play before dropping 28 + baseDelayMs: 5_000, // initial backoff (5s) 29 + maxDelayMs: 600_000, // cap at 10 min 30 + flushIntervalMs: 30_000, // periodic retry sweep (30s) 31 + maxQueueSize: 500, // drop oldest plays if queue exceeds this 32 + }; 33 + 34 + class RetryQueue extends EventEmitter { 35 + /** 36 + * @param {Object} [opts] 37 + * @param {number} [opts.maxAttempts] 38 + * @param {number} [opts.baseDelayMs] 39 + * @param {number} [opts.maxDelayMs] 40 + * @param {number} [opts.flushIntervalMs] 41 + * @param {number} [opts.maxQueueSize] 42 + * @param {string} [opts.persistPath] File path for disk persistence. If omitted, in-memory only. 43 + */ 44 + constructor(opts = {}) { 45 + super(); 46 + this._opts = { ...DEFAULTS, ...opts }; 47 + 48 + /** @type {Array<{ playData: object, attempts: number, nextAttempt: number }>} */ 49 + this._queue = []; 50 + 51 + /** @type {Function|null} Submit function provided by TealClient */ 52 + this._submitFn = null; 53 + 54 + this._flushTimer = null; 55 + this._flushing = false; 56 + this._persistTimer = null; 57 + 58 + // Load persisted queue from disk. 59 + if (this._opts.persistPath) { 60 + this._load(); 61 + } 62 + } 63 + 64 + /* ── Public API ─────────────────────────────────────────────────── */ 65 + 66 + /** 67 + * Register the submission function. Typically: 68 + * queue.setSubmitFn((playData) => tealClient.submit(playData)); 69 + * 70 + * @param {Function} fn Async function: playData → { uri, cid } 71 + */ 72 + setSubmitFn(fn) { 73 + this._submitFn = fn; 74 + } 75 + 76 + /** 77 + * Add a failed play to the queue. The play will be retried according 78 + * to the backoff schedule on the next flush cycle. 79 + * 80 + * @param {object} playData The play record to retry. 81 + */ 82 + enqueue(playData) { 83 + // Enforce max queue size (drop oldest). 84 + if (this._queue.length >= this._opts.maxQueueSize) { 85 + const dropped = this._queue.shift(); 86 + this.emit("dropped", { playData: dropped.playData }); 87 + console.warn( 88 + `[retry] Queue full (${this._opts.maxQueueSize}) — dropping oldest play` 89 + ); 90 + } 91 + 92 + const entry = { 93 + playData, 94 + attempts: 0, 95 + nextAttempt: Date.now() + this._delayFor(0), 96 + }; 97 + this._queue.push(entry); 98 + this._persist(); 99 + 100 + this.emit("enqueue", { playData, queueSize: this._queue.length }); 101 + } 102 + 103 + /** 104 + * Start the periodic retry flush. Requires setSubmitFn() to have been called. 105 + */ 106 + start() { 107 + if (this._flushTimer) return; 108 + this._flushTimer = setInterval( 109 + () => this.flush(), 110 + this._opts.flushIntervalMs 111 + ); 112 + // Don't keep the process alive just for the timer. 113 + if (this._flushTimer.unref) this._flushTimer.unref(); 114 + } 115 + 116 + /** 117 + * Stop the periodic retry flush. 118 + */ 119 + stop() { 120 + if (this._flushTimer) { 121 + clearInterval(this._flushTimer); 122 + this._flushTimer = null; 123 + } 124 + if (this._persistTimer) { 125 + clearTimeout(this._persistTimer); 126 + this._persistTimer = null; 127 + } 128 + this._persistSync(); 129 + } 130 + 131 + /** 132 + * Attempt to submit all due plays. Called periodically by start() 133 + * or manually after the client reconnects. 134 + */ 135 + async flush() { 136 + if (this._flushing || !this._submitFn || this._queue.length === 0) { 137 + return; 138 + } 139 + this._flushing = true; 140 + 141 + try { 142 + const now = Date.now(); 143 + const due = this._queue.filter((e) => e.nextAttempt <= now); 144 + 145 + for (const entry of due) { 146 + const success = await this._trySubmit(entry); 147 + if (success) { 148 + // Remove from queue. 149 + const idx = this._queue.indexOf(entry); 150 + if (idx !== -1) this._queue.splice(idx, 1); 151 + } else { 152 + // Check if exhausted. 153 + if (entry.attempts >= this._opts.maxAttempts) { 154 + const idx = this._queue.indexOf(entry); 155 + if (idx !== -1) this._queue.splice(idx, 1); 156 + this.emit("dropped", { playData: entry.playData }); 157 + console.warn( 158 + `[retry] Play dropped after ${entry.attempts} attempts` 159 + ); 160 + } 161 + } 162 + } 163 + 164 + this._persist(); 165 + 166 + if (this._queue.length === 0) { 167 + this.emit("drained"); 168 + } 169 + } finally { 170 + this._flushing = false; 171 + } 172 + } 173 + 174 + /** 175 + * Current queue depth. 176 + * @returns {number} 177 + */ 178 + get size() { 179 + return this._queue.length; 180 + } 181 + 182 + /* ── Internals ──────────────────────────────────────────────────── */ 183 + 184 + /** 185 + * Attempt a single submission, updating the entry's attempt count 186 + * and next-attempt time. 187 + * 188 + * @returns {Promise<boolean>} true if successful, false if it should be retried. 189 + */ 190 + async _trySubmit(entry) { 191 + entry.attempts++; 192 + 193 + try { 194 + const result = await this._submitFn(entry.playData); 195 + this.emit("retried", { playData: entry.playData, result }); 196 + console.log( 197 + `[retry] Play submitted after ${entry.attempts} attempt(s)` 198 + ); 199 + return true; 200 + } catch (err) { 201 + this.emit("failed", { playData: entry.playData, error: err }); 202 + console.error( 203 + `[retry] Attempt ${entry.attempts}/${this._opts.maxAttempts} failed: ${err.message}` 204 + ); 205 + 206 + // Schedule next attempt with backoff. 207 + entry.nextAttempt = Date.now() + this._delayFor(entry.attempts); 208 + return false; 209 + } 210 + } 211 + 212 + /** 213 + * Compute the backoff delay for a given attempt number (0-indexed). 214 + * Exponential: base * 2^attempt, capped at maxDelay, with ±25% jitter. 215 + * 216 + * @param {number} attempt Zero-indexed attempt number. 217 + * @returns {number} Delay in milliseconds. 218 + */ 219 + _delayFor(attempt) { 220 + const exponential = this._opts.baseDelayMs * Math.pow(2, attempt); 221 + const capped = Math.min(exponential, this._opts.maxDelayMs); 222 + const jitter = capped * (0.75 + Math.random() * 0.5); // ±25% 223 + return Math.floor(jitter); 224 + } 225 + 226 + /* ── Persistence ────────────────────────────────────────────────── */ 227 + 228 + _persist() { 229 + if (!this._opts.persistPath) return; 230 + if (this._persistTimer) return; 231 + this._persistTimer = setTimeout(() => { 232 + this._persistTimer = null; 233 + this._persistNow(); 234 + }, 500); 235 + if (this._persistTimer.unref) this._persistTimer.unref(); 236 + } 237 + 238 + _persistSync() { 239 + if (!this._opts.persistPath) return; 240 + try { 241 + const dir = path.dirname(this._opts.persistPath); 242 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 243 + fs.writeFileSync(this._opts.persistPath, JSON.stringify(this._queue), "utf8"); 244 + } catch (err) { 245 + console.warn(`[retry] Failed to persist queue: ${err.message}`); 246 + } 247 + } 248 + 249 + _persistNow() { 250 + if (!this._opts.persistPath) return; 251 + try { 252 + const dir = path.dirname(this._opts.persistPath); 253 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 254 + fs.writeFile( 255 + this._opts.persistPath, 256 + JSON.stringify(this._queue), 257 + "utf8", 258 + (err) => { 259 + if (err) console.warn(`[retry] Failed to persist queue: ${err.message}`); 260 + } 261 + ); 262 + } catch (err) { 263 + console.warn(`[retry] Failed to persist queue: ${err.message}`); 264 + } 265 + } 266 + 267 + /** 268 + * Load the queue from disk (best-effort). 269 + */ 270 + _load() { 271 + if (!this._opts.persistPath) return; 272 + try { 273 + if (!fs.existsSync(this._opts.persistPath)) return; 274 + const data = fs.readFileSync(this._opts.persistPath, "utf8"); 275 + const parsed = JSON.parse(data); 276 + if (Array.isArray(parsed)) { 277 + // Reset attempt timing so persisted plays are retried soon. 278 + this._queue = parsed.map((e) => ({ 279 + playData: e.playData, 280 + attempts: e.attempts || 0, 281 + nextAttempt: Date.now(), 282 + })); 283 + if (this._queue.length > 0) { 284 + console.log( 285 + `[retry] Loaded ${this._queue.length} queued play(s) from disk` 286 + ); 287 + } 288 + } 289 + } catch (err) { 290 + console.warn(`[retry] Failed to load persisted queue: ${err.message}`); 291 + } 292 + } 293 + } 294 + 295 + module.exports = { RetryQueue };
+724
src/teal-client.js
··· 1 + "use strict"; 2 + 3 + const { EventEmitter } = require("events"); 4 + const { 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 + */ 12 + const 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 + */ 19 + const CLIENT_AGENT = "fm.teal.roon-scrobbler/0.1.0"; 20 + 21 + /** 22 + * The teal.fm feed.play lexicon collection NSID. 23 + * @type {string} 24 + */ 25 + const COLLECTION = "fm.teal.alpha.feed.play"; 26 + 27 + /** 28 + * Default handle-resolution endpoint (Bluesky's AppView). 29 + * @type {string} 30 + */ 31 + const DEFAULT_HANDLE_RESOLVER = "https://bsky.social"; 32 + 33 + /** 34 + * PLC directory base URL for resolving `did:plc:` identifiers. 35 + * @type {string} 36 + */ 37 + const 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 + */ 46 + class 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). */ 66 + class 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`. */ 74 + class 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 + */ 92 + function 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 + */ 120 + function 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 + */ 146 + class 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 + 715 + module.exports = { 716 + TealClient, 717 + TealSubmissionError, 718 + TealAuthError, 719 + TealRateLimitError, 720 + generateTid, 721 + parseArtists, 722 + CLIENT_AGENT, 723 + COLLECTION, 724 + };
+190
src/zone-watcher.js
··· 1 + "use strict"; 2 + 3 + const { EventEmitter } = require("events"); 4 + 5 + /** 6 + * ZoneWatcher subscribes to Roon transport zone updates and maintains 7 + * an in-memory snapshot of every zone's state. It emits granular 8 + * events that downstream consumers (allowlist filter, progress tracker, 9 + * scrobble client) can subscribe to. 10 + * 11 + * Events: 12 + * - "zone_added" { zone } A new zone appeared. 13 + * - "zone_removed" { zone_id } A zone disappeared. 14 + * - "zone_changed" { zone, prev } A zone's state or now_playing changed. 15 + * - "state_changed" { zone, prev_state } Convenience: playback state transition. 16 + * - "track_changed" { zone, prev_now_playing } New track in a zone. 17 + * - "seek" { zone_id, seek_position } Periodic seek update (for progress tracking). 18 + * - "subscribed" { zones } Initial subscription payload (full zone list). 19 + * - "unsubscribed" Subscription lost. 20 + */ 21 + class ZoneWatcher extends EventEmitter { 22 + constructor() { 23 + super(); 24 + /** @type {Map<string, object>} zone_id → zone snapshot */ 25 + this._zones = new Map(); 26 + } 27 + 28 + /** 29 + * Attach to a paired core's transport service and subscribe to zones. 30 + * @param {object} core - Roon core object (from core_paired callback) 31 + */ 32 + subscribe(core) { 33 + const transport = core.services.RoonApiTransport; 34 + 35 + transport.subscribe_zones((response, msg) => { 36 + switch (response) { 37 + case "Subscribed": 38 + this._onSubscribed(msg); 39 + break; 40 + case "Changed": 41 + this._onChanged(msg); 42 + break; 43 + case "Unsubscribed": 44 + this._onUnsubscribed(); 45 + break; 46 + default: 47 + // Unknown response — ignore but don't crash. 48 + break; 49 + } 50 + }); 51 + } 52 + 53 + /* ── Internal handlers ────────────────────────────────────────── */ 54 + 55 + _onSubscribed(msg) { 56 + this._zones.clear(); 57 + 58 + const zones = msg.zones || []; 59 + for (const zone of zones) { 60 + this._zones.set(zone.zone_id, zone); 61 + } 62 + 63 + this.emit("subscribed", { zones: Array.from(this._zones.values()) }); 64 + 65 + for (const zone of this._zones.values()) { 66 + this.emit("zone_added", { zone }); 67 + } 68 + } 69 + 70 + _onChanged(msg) { 71 + // Removed zones 72 + if (msg.zones_removed) { 73 + for (const entry of msg.zones_removed) { 74 + const prev = this._zones.get(entry.zone_id); 75 + this._zones.delete(entry.zone_id); 76 + this.emit("zone_removed", { zone_id: entry.zone_id, zone: prev }); 77 + } 78 + } 79 + 80 + // Added zones 81 + if (msg.zones_added) { 82 + for (const zone of msg.zones_added) { 83 + this._zones.set(zone.zone_id, zone); 84 + this.emit("zone_added", { zone }); 85 + } 86 + } 87 + 88 + // Changed zones 89 + if (msg.zones_changed) { 90 + for (const zone of msg.zones_changed) { 91 + const prev = this._zones.get(zone.zone_id); 92 + this._zones.set(zone.zone_id, zone); 93 + 94 + this.emit("zone_changed", { zone, prev }); 95 + 96 + // Fine-grained events 97 + if (prev && prev.state !== zone.state) { 98 + this.emit("state_changed", { 99 + zone, 100 + prev_state: prev.state, 101 + }); 102 + } 103 + 104 + if (this._trackChanged(prev, zone)) { 105 + this.emit("track_changed", { 106 + zone, 107 + prev_now_playing: prev ? prev.now_playing : undefined, 108 + }); 109 + } 110 + 111 + // Always emit seek updates for progress tracking 112 + if (zone.now_playing && typeof zone.now_playing.seek_position === "number") { 113 + this.emit("seek", { 114 + zone_id: zone.zone_id, 115 + seek_position: zone.now_playing.seek_position, 116 + length: zone.now_playing.length, 117 + }); 118 + } 119 + } 120 + } 121 + 122 + // Zone seek-only updates (Roon batches frequent seek_position changes) 123 + if (msg.zone_seek_changed) { 124 + for (const entry of msg.zone_seek_changed) { 125 + const zone = this._zones.get(entry.zone_id); 126 + if (zone && zone.now_playing) { 127 + const updated = { 128 + ...zone, 129 + now_playing: { 130 + ...zone.now_playing, 131 + seek_position: entry.seek_position, 132 + }, 133 + }; 134 + this._zones.set(entry.zone_id, updated); 135 + this.emit("seek", { 136 + zone_id: entry.zone_id, 137 + seek_position: entry.seek_position, 138 + length: zone.now_playing.length, 139 + }); 140 + } 141 + } 142 + } 143 + } 144 + 145 + _onUnsubscribed() { 146 + this._zones.clear(); 147 + this.emit("unsubscribed"); 148 + } 149 + 150 + /** 151 + * Determine if the now-playing track has changed between snapshots. 152 + * Uses three_line lines as the identity key. 153 + */ 154 + _trackChanged(prev, cur) { 155 + const p = prev && prev.now_playing ? prev.now_playing : null; 156 + const c = cur && cur.now_playing ? cur.now_playing : null; 157 + 158 + // Track changed if either side is null/undefined 159 + if (!p || !c) return p !== c; 160 + 161 + const pl = p.three_line || {}; 162 + const cl = c.three_line || {}; 163 + 164 + return ( 165 + pl.line1 !== cl.line1 || 166 + pl.line2 !== cl.line2 || 167 + pl.line3 !== cl.line3 168 + ); 169 + } 170 + 171 + /* ── Query helpers ─────────────────────────────────────────────── */ 172 + 173 + /** 174 + * Get the current snapshot for a zone. 175 + * @returns {object|undefined} 176 + */ 177 + getZone(zone_id) { 178 + return this._zones.get(zone_id); 179 + } 180 + 181 + /** 182 + * Get all current zone snapshots. 183 + * @returns {object[]} 184 + */ 185 + getAllZones() { 186 + return Array.from(this._zones.values()); 187 + } 188 + } 189 + 190 + module.exports = { ZoneWatcher };
+173
test/allowlist.test.js
··· 1 + "use strict"; 2 + 3 + const { describe, it, beforeEach } = require("node:test"); 4 + const assert = require("node:assert/strict"); 5 + 6 + const { ZoneAllowlist, NUM_ZONE_SLOTS } = require("../src/allowlist"); 7 + const { EventEmitter } = require("events"); 8 + 9 + describe("ZoneAllowlist", () => { 10 + describe("isAllowed", () => { 11 + it("allows all zones when allowlist is empty", () => { 12 + const al = new ZoneAllowlist({ allowedZoneIds: [] }); 13 + assert.ok(al.isAllowed("any-zone", "Any Name")); 14 + }); 15 + 16 + it("filters by zone ID", () => { 17 + const al = new ZoneAllowlist({ allowedZoneIds: ["z1", "z2"] }); 18 + assert.ok(al.isAllowed("z1")); 19 + assert.ok(al.isAllowed("z2")); 20 + assert.ok(!al.isAllowed("z3")); 21 + }); 22 + 23 + it("filters by display name (case-insensitive)", () => { 24 + const al = new ZoneAllowlist({ 25 + allowedZoneIds: [], 26 + allowedZoneNames: ["Kitchen"], 27 + }); 28 + assert.ok(al.isAllowed("z1", "Kitchen")); 29 + assert.ok(al.isAllowed("z1", "kitchen")); 30 + assert.ok(al.isAllowed("z1", "KITCHEN")); 31 + assert.ok(!al.isAllowed("z1", "Bedroom")); 32 + }); 33 + 34 + it("matches base name of grouped zones", () => { 35 + const al = new ZoneAllowlist({ 36 + allowedZoneIds: [], 37 + allowedZoneNames: ["Kitchen"], 38 + }); 39 + assert.ok(al.isAllowed("z1", "Kitchen + 2")); 40 + assert.ok(al.isAllowed("z1", "Kitchen + Living Room")); 41 + }); 42 + 43 + it("returns false for falsy zone_id", () => { 44 + const al = new ZoneAllowlist({ allowedZoneIds: ["z1"] }); 45 + assert.ok(!al.isAllowed(null)); 46 + assert.ok(!al.isAllowed(undefined)); 47 + assert.ok(!al.isAllowed("")); 48 + }); 49 + }); 50 + 51 + describe("reconfigure", () => { 52 + it("updates the allowlist and re-evaluates playing zones", () => { 53 + const al = new ZoneAllowlist({ allowedZoneIds: [] }); 54 + const watcher = new EventEmitter(); 55 + al.attach(watcher); 56 + 57 + const zone = { 58 + zone_id: "z1", 59 + display_name: "Kitchen", 60 + state: "playing", 61 + now_playing: { three_line: { line1: "A", line2: "T", line3: "Al" } }, 62 + }; 63 + watcher.emit("zone_added", { zone }); 64 + 65 + let stopped = false; 66 + al.on("playback_stopped", () => { stopped = true; }); 67 + 68 + al.reconfigure({ allowedZoneIds: ["z2"], allowedZoneNames: [] }); 69 + assert.ok(stopped, "should emit playback_stopped for newly-disallowed zone"); 70 + }); 71 + }); 72 + 73 + describe("grouped zone dedup", () => { 74 + let al; 75 + let watcher; 76 + 77 + beforeEach(() => { 78 + al = new ZoneAllowlist({ allowedZoneIds: [] }); 79 + watcher = new EventEmitter(); 80 + al.attach(watcher); 81 + }); 82 + 83 + it("emits playback_started only for the leader zone", () => { 84 + let startCount = 0; 85 + al.on("playback_started", () => startCount++); 86 + 87 + const zone1 = { 88 + zone_id: "z1", 89 + display_name: "Kitchen", 90 + state: "playing", 91 + now_playing: { three_line: { line1: "A", line2: "T", line3: "Al" } }, 92 + }; 93 + const zone2 = { 94 + zone_id: "z2", 95 + display_name: "Living Room", 96 + state: "playing", 97 + now_playing: { three_line: { line1: "A", line2: "T", line3: "Al" } }, 98 + }; 99 + 100 + watcher.emit("state_changed", { zone: zone1, prev_state: "stopped" }); 101 + watcher.emit("state_changed", { zone: zone2, prev_state: "stopped" }); 102 + 103 + assert.equal(startCount, 1, "only the leader should trigger playback_started"); 104 + }); 105 + 106 + it("promotes a secondary when the leader stops", () => { 107 + const track = { line1: "A", line2: "T", line3: "Al" }; 108 + 109 + const zone1 = { 110 + zone_id: "z1", display_name: "Kitchen", state: "playing", 111 + now_playing: { three_line: track }, 112 + }; 113 + const zone2 = { 114 + zone_id: "z2", display_name: "Living Room", state: "playing", 115 + now_playing: { three_line: track }, 116 + }; 117 + 118 + watcher.emit("zone_added", { zone: zone1 }); 119 + watcher.emit("zone_added", { zone: zone2 }); 120 + 121 + let stopCount = 0; 122 + al.on("playback_stopped", () => stopCount++); 123 + 124 + const zone1Stopped = { ...zone1, state: "stopped" }; 125 + watcher.emit("state_changed", { zone: zone1Stopped, prev_state: "playing" }); 126 + 127 + assert.equal(stopCount, 0, "should promote secondary, not emit stop"); 128 + }); 129 + }); 130 + 131 + describe("parseSettings", () => { 132 + it("parses comma-separated zone IDs", () => { 133 + const result = ZoneAllowlist.parseSettings({ 134 + allowed_zone_ids: "z1, z2, z3", 135 + }); 136 + assert.deepEqual(result.allowedZoneIds.sort(), ["z1", "z2", "z3"]); 137 + }); 138 + 139 + it("parses zone picker slots", () => { 140 + const result = ZoneAllowlist.parseSettings({ 141 + allowed_zone_ids: "", 142 + allow_zone_1: "z1", 143 + allow_zone_2: "z2", 144 + }); 145 + assert.deepEqual(result.allowedZoneIds.sort(), ["z1", "z2"]); 146 + }); 147 + 148 + it("deduplicates across sources", () => { 149 + const result = ZoneAllowlist.parseSettings({ 150 + allowed_zone_ids: "z1", 151 + allow_zone_1: "z1", 152 + }); 153 + assert.deepEqual(result.allowedZoneIds, ["z1"]); 154 + }); 155 + 156 + it("returns empty for no input", () => { 157 + const result = ZoneAllowlist.parseSettings({}); 158 + assert.deepEqual(result.allowedZoneIds, []); 159 + }); 160 + }); 161 + 162 + describe("getSettingsLayout", () => { 163 + it("returns layout with zone ID field + per-slot zone pickers", () => { 164 + const layout = ZoneAllowlist.getSettingsLayout({}); 165 + assert.equal(layout[0].setting, "allowed_zone_ids"); 166 + assert.equal(layout.length, 1 + NUM_ZONE_SLOTS); 167 + for (let i = 1; i <= NUM_ZONE_SLOTS; i++) { 168 + assert.equal(layout[i].setting, `allow_zone_${i}`); 169 + assert.equal(layout[i].type, "zone"); 170 + } 171 + }); 172 + }); 173 + });
+217
test/progress-tracker.test.js
··· 1 + "use strict"; 2 + 3 + const { describe, it, beforeEach } = require("node:test"); 4 + const assert = require("node:assert/strict"); 5 + 6 + const { computeThreshold, ProgressTracker } = require("../src/progress-tracker"); 7 + const { EventEmitter } = require("events"); 8 + 9 + describe("computeThreshold", () => { 10 + it("returns DEFAULT_THRESHOLD_SECONDS for undefined duration", () => { 11 + assert.equal(computeThreshold(undefined), 120); 12 + }); 13 + 14 + it("returns DEFAULT_THRESHOLD_SECONDS for non-positive duration", () => { 15 + assert.equal(computeThreshold(0), 120); 16 + assert.equal(computeThreshold(-10), 120); 17 + }); 18 + 19 + it("returns DEFAULT_THRESHOLD_SECONDS for NaN / Infinity", () => { 20 + assert.equal(computeThreshold(NaN), 120); 21 + assert.equal(computeThreshold(Infinity), 120); 22 + assert.equal(computeThreshold(-Infinity), 120); 23 + }); 24 + 25 + it("returns DEFAULT_THRESHOLD_SECONDS for non-number types", () => { 26 + assert.equal(computeThreshold("300"), 120); 27 + assert.equal(computeThreshold(null), 120); 28 + }); 29 + 30 + it("short track (90s): threshold = whole track", () => { 31 + // max(min(90, 120), min(45, 240)) = max(90, 45) = 90 32 + assert.equal(computeThreshold(90), 90); 33 + }); 34 + 35 + it("medium track (200s): threshold = 120s cap", () => { 36 + // max(min(200, 120), min(100, 240)) = max(120, 100) = 120 37 + assert.equal(computeThreshold(200), 120); 38 + }); 39 + 40 + it("long track (600s): threshold = 240s (half-track cap)", () => { 41 + // max(min(600, 120), min(300, 240)) = max(120, 240) = 240 42 + assert.equal(computeThreshold(600), 240); 43 + }); 44 + 45 + it("very long track (3600s): threshold = 240s cap", () => { 46 + // max(min(3600, 120), min(1800, 240)) = max(120, 240) = 240 47 + assert.equal(computeThreshold(3600), 240); 48 + }); 49 + 50 + it("very short track (30s): threshold = 30s (whole track)", () => { 51 + // max(min(30, 120), min(15, 240)) = max(30, 15) = 30 52 + assert.equal(computeThreshold(30), 30); 53 + }); 54 + 55 + it("boundary: exactly 120s track", () => { 56 + // max(min(120, 120), min(60, 240)) = max(120, 60) = 120 57 + assert.equal(computeThreshold(120), 120); 58 + }); 59 + 60 + it("boundary: exactly 480s (half = 240)", () => { 61 + // max(min(480, 120), min(240, 240)) = max(120, 240) = 240 62 + assert.equal(computeThreshold(480), 240); 63 + }); 64 + }); 65 + 66 + describe("ProgressTracker", () => { 67 + let tracker; 68 + let watcher; 69 + 70 + beforeEach(() => { 71 + tracker = new ProgressTracker(); 72 + watcher = new EventEmitter(); 73 + tracker.attach(watcher); 74 + }); 75 + 76 + function makeZone(zone_id, state, line1, line2, line3, length, seek_position) { 77 + return { 78 + zone_id, 79 + display_name: "Test", 80 + state, 81 + now_playing: { 82 + three_line: { line1, line2, line3 }, 83 + length, 84 + seek_position: seek_position ?? 0, 85 + }, 86 + }; 87 + } 88 + 89 + it("emits track_started on zone_added with now_playing", () => { 90 + let started = false; 91 + tracker.on("track_started", ({ zone_id, track }) => { 92 + started = true; 93 + assert.equal(zone_id, "z1"); 94 + assert.equal(track.line2, "Song"); 95 + }); 96 + 97 + watcher.emit("zone_added", { 98 + zone: makeZone("z1", "playing", "Artist", "Song", "Album", 300), 99 + }); 100 + 101 + assert.ok(started); 102 + }); 103 + 104 + it("emits qualified_play after sufficient seek updates", () => { 105 + const zone = makeZone("z1", "playing", "Artist", "Song", "Album", 60); 106 + 107 + watcher.emit("zone_added", { zone }); 108 + 109 + let qualified = false; 110 + tracker.on("qualified_play", ({ zone_id, listened_seconds, threshold }) => { 111 + qualified = true; 112 + assert.equal(zone_id, "z1"); 113 + assert.equal(threshold, 60); 114 + assert.ok(listened_seconds >= 60); 115 + }); 116 + 117 + const st = tracker._zones.get("z1"); 118 + st.listened = 59; 119 + st.last_ts = Date.now() - 2000; 120 + st.position = 58; 121 + 122 + watcher.emit("seek", { zone_id: "z1", seek_position: 60, length: 60 }); 123 + 124 + assert.ok(qualified); 125 + }); 126 + 127 + it("emits qualified_play at most once per track", () => { 128 + const zone = makeZone("z1", "playing", "Artist", "Song", "Album", 60); 129 + watcher.emit("zone_added", { zone }); 130 + 131 + let count = 0; 132 + tracker.on("qualified_play", () => count++); 133 + 134 + const st = tracker._zones.get("z1"); 135 + st.listened = 61; 136 + st.last_ts = Date.now() - 1000; 137 + st.position = 60; 138 + 139 + watcher.emit("seek", { zone_id: "z1", seek_position: 61, length: 60 }); 140 + watcher.emit("seek", { zone_id: "z1", seek_position: 62, length: 60 }); 141 + 142 + assert.equal(count, 1); 143 + }); 144 + 145 + it("emits track_reset when track changes before qualifying", () => { 146 + const zone1 = makeZone("z1", "playing", "A", "Track1", "Al", 300); 147 + watcher.emit("zone_added", { zone: zone1 }); 148 + 149 + let resetFired = false; 150 + tracker.on("track_reset", ({ zone_id, track }) => { 151 + resetFired = true; 152 + assert.equal(track.line2, "Track1"); 153 + }); 154 + 155 + const zone2 = makeZone("z1", "playing", "A", "Track2", "Al", 300); 156 + watcher.emit("track_changed", { zone: zone2, prev_now_playing: zone1.now_playing }); 157 + 158 + assert.ok(resetFired); 159 + }); 160 + 161 + it("does not credit time beyond STALE_WINDOW_SECONDS", () => { 162 + const zone = makeZone("z1", "playing", "A", "T", "Al", 300); 163 + watcher.emit("zone_added", { zone }); 164 + 165 + const st = tracker._zones.get("z1"); 166 + st.listened = 0; 167 + st.last_ts = Date.now() - 30_000; 168 + st.position = 0; 169 + 170 + watcher.emit("seek", { zone_id: "z1", seek_position: 30, length: 300 }); 171 + 172 + assert.ok(st.listened < 1, `expected near-zero listened, got ${st.listened}`); 173 + }); 174 + 175 + it("handles backward seek by reducing accumulated time", () => { 176 + const zone = makeZone("z1", "playing", "A", "T", "Al", 300); 177 + watcher.emit("zone_added", { zone }); 178 + 179 + const st = tracker._zones.get("z1"); 180 + st.listened = 50; 181 + st.position = 50; 182 + st.last_ts = Date.now() - 1000; 183 + 184 + watcher.emit("seek", { zone_id: "z1", seek_position: 10, length: 300 }); 185 + 186 + assert.ok(st.listened < 50, `expected listened < 50 after rewind, got ${st.listened}`); 187 + assert.ok(st.listened >= 0, "listened should never go negative"); 188 + }); 189 + 190 + it("getProgress returns current state", () => { 191 + const zone = makeZone("z1", "playing", "A", "T", "Al", 200); 192 + watcher.emit("zone_added", { zone }); 193 + 194 + const progress = tracker.getProgress("z1"); 195 + assert.equal(progress.zone_id, "z1"); 196 + assert.equal(progress.duration, 200); 197 + assert.equal(progress.is_playing, true); 198 + assert.equal(progress.scrobbled, false); 199 + }); 200 + 201 + it("getProgress returns undefined for unknown zones", () => { 202 + assert.equal(tracker.getProgress("unknown"), undefined); 203 + }); 204 + 205 + it("detach removes all listeners", () => { 206 + tracker.detach(watcher); 207 + 208 + let started = false; 209 + tracker.on("track_started", () => { started = true; }); 210 + 211 + watcher.emit("zone_added", { 212 + zone: makeZone("z1", "playing", "A", "T", "Al", 300), 213 + }); 214 + 215 + assert.ok(!started); 216 + }); 217 + });
+158
test/teal-client.test.js
··· 1 + "use strict"; 2 + 3 + const { describe, it } = require("node:test"); 4 + const assert = require("node:assert/strict"); 5 + 6 + const { 7 + generateTid, 8 + parseArtists, 9 + TealClient, 10 + CLIENT_AGENT, 11 + } = require("../src/teal-client"); 12 + 13 + describe("generateTid", () => { 14 + it("returns a 12-character string", () => { 15 + const tid = generateTid(); 16 + assert.equal(tid.length, 12); 17 + }); 18 + 19 + it("uses only the AT Protocol base32 alphabet", () => { 20 + const valid = /^[234567a-z]+$/; 21 + for (let i = 0; i < 50; i++) { 22 + assert.match(generateTid(), valid); 23 + } 24 + }); 25 + 26 + it("produces mostly unique values across rapid calls", () => { 27 + const tids = new Set(); 28 + for (let i = 0; i < 100; i++) { 29 + tids.add(generateTid()); 30 + } 31 + // With 2 random chars (1024 combinations) and ms-resolution timestamps, 32 + // some collisions are possible in a tight loop. Expect >90% unique. 33 + assert.ok(tids.size > 90, `expected >90 unique TIDs, got ${tids.size}`); 34 + }); 35 + }); 36 + 37 + describe("parseArtists", () => { 38 + it("returns empty array for falsy input", () => { 39 + assert.deepEqual(parseArtists(null), []); 40 + assert.deepEqual(parseArtists(undefined), []); 41 + assert.deepEqual(parseArtists(""), []); 42 + }); 43 + 44 + it("returns empty array for non-string input", () => { 45 + assert.deepEqual(parseArtists(42), []); 46 + assert.deepEqual(parseArtists({}), []); 47 + }); 48 + 49 + it("parses a single artist", () => { 50 + assert.deepEqual(parseArtists("Radiohead"), [ 51 + { artistName: "Radiohead" }, 52 + ]); 53 + }); 54 + 55 + it("splits on commas", () => { 56 + assert.deepEqual(parseArtists("A, B, C"), [ 57 + { artistName: "A" }, 58 + { artistName: "B" }, 59 + { artistName: "C" }, 60 + ]); 61 + }); 62 + 63 + it("splits on ampersands", () => { 64 + assert.deepEqual(parseArtists("A & B"), [ 65 + { artistName: "A" }, 66 + { artistName: "B" }, 67 + ]); 68 + }); 69 + 70 + it("splits on slashes", () => { 71 + assert.deepEqual(parseArtists("A / B"), [ 72 + { artistName: "A" }, 73 + { artistName: "B" }, 74 + ]); 75 + }); 76 + 77 + it("splits on feat. and ft.", () => { 78 + assert.deepEqual(parseArtists("A feat. B"), [ 79 + { artistName: "A" }, 80 + { artistName: "B" }, 81 + ]); 82 + assert.deepEqual(parseArtists("A ft. B"), [ 83 + { artistName: "A" }, 84 + { artistName: "B" }, 85 + ]); 86 + }); 87 + 88 + it("handles mixed delimiters", () => { 89 + const result = parseArtists("A, B & C feat. D"); 90 + assert.equal(result.length, 4); 91 + assert.deepEqual(result.map((a) => a.artistName), ["A", "B", "C", "D"]); 92 + }); 93 + 94 + it("trims whitespace from artist names", () => { 95 + assert.deepEqual(parseArtists(" A , B "), [ 96 + { artistName: "A" }, 97 + { artistName: "B" }, 98 + ]); 99 + }); 100 + }); 101 + 102 + describe("TealClient.buildPlayRecord", () => { 103 + const makeZone = (line1, line2, line3, length) => ({ 104 + zone_id: "zone-1", 105 + display_name: "Test Zone", 106 + state: "playing", 107 + now_playing: { 108 + three_line: { line1, line2, line3 }, 109 + length, 110 + seek_position: 0, 111 + }, 112 + }); 113 + 114 + it("extracts track metadata from zone snapshot", () => { 115 + const zone = makeZone("Artist", "Track Title", "Album Name", 300); 116 + const record = TealClient.buildPlayRecord(zone, { duration: 300 }); 117 + 118 + assert.equal(record.trackName, "Track Title"); 119 + assert.deepEqual(record.artists, [{ artistName: "Artist" }]); 120 + assert.equal(record.releaseName, "Album Name"); 121 + assert.equal(record.duration, 300); 122 + assert.equal(record.submissionClientAgent, CLIENT_AGENT); 123 + }); 124 + 125 + it("defaults musicServiceBaseDomain to 'local'", () => { 126 + const zone = makeZone("A", "T", "Al", 100); 127 + const record = TealClient.buildPlayRecord(zone, { duration: 100 }); 128 + assert.equal(record.musicServiceBaseDomain, "local"); 129 + }); 130 + 131 + it("uses provided musicServiceBaseDomain", () => { 132 + const zone = makeZone("A", "T", "Al", 100); 133 + const record = TealClient.buildPlayRecord(zone, { duration: 100 }, "tidal.com"); 134 + assert.equal(record.musicServiceBaseDomain, "tidal.com"); 135 + }); 136 + 137 + it("falls back to now_playing.length when duration not in qualifiedPlayData", () => { 138 + const zone = makeZone("A", "T", "Al", 250); 139 + const record = TealClient.buildPlayRecord(zone, {}); 140 + assert.equal(record.duration, 250); 141 + }); 142 + 143 + it("handles missing now_playing gracefully", () => { 144 + const zone = { zone_id: "z", display_name: "Z", state: "stopped" }; 145 + const record = TealClient.buildPlayRecord(zone, {}); 146 + assert.equal(record.trackName, "Unknown Track"); 147 + assert.deepEqual(record.artists, []); 148 + assert.equal(record.releaseName, undefined); 149 + assert.equal(record.duration, undefined); 150 + }); 151 + 152 + it("produces a valid ISO timestamp for playedTime", () => { 153 + const zone = makeZone("A", "T", "Al", 100); 154 + const record = TealClient.buildPlayRecord(zone, {}); 155 + assert.doesNotThrow(() => new Date(record.playedTime)); 156 + assert.match(record.playedTime, /^\d{4}-\d{2}-\d{2}T/); 157 + }); 158 + });
+190
test/zone-watcher.test.js
··· 1 + "use strict"; 2 + 3 + const { describe, it, beforeEach } = require("node:test"); 4 + const assert = require("node:assert/strict"); 5 + 6 + const { ZoneWatcher } = require("../src/zone-watcher"); 7 + 8 + describe("ZoneWatcher", () => { 9 + let watcher; 10 + 11 + beforeEach(() => { 12 + watcher = new ZoneWatcher(); 13 + }); 14 + 15 + function fakeCore(callback) { 16 + return { 17 + services: { 18 + RoonApiTransport: { 19 + subscribe_zones: callback, 20 + }, 21 + }, 22 + }; 23 + } 24 + 25 + describe("subscribe + Subscribed", () => { 26 + it("emits subscribed event with all zones", () => { 27 + let subscribedPayload; 28 + watcher.on("subscribed", (data) => { subscribedPayload = data; }); 29 + 30 + const zones = [ 31 + { zone_id: "z1", state: "playing" }, 32 + { zone_id: "z2", state: "stopped" }, 33 + ]; 34 + 35 + const core = fakeCore((cb) => cb("Subscribed", { zones })); 36 + watcher.subscribe(core); 37 + 38 + assert.equal(subscribedPayload.zones.length, 2); 39 + }); 40 + 41 + it("emits zone_added for each zone", () => { 42 + const added = []; 43 + watcher.on("zone_added", ({ zone }) => added.push(zone.zone_id)); 44 + 45 + const core = fakeCore((cb) => cb("Subscribed", { 46 + zones: [{ zone_id: "z1" }, { zone_id: "z2" }], 47 + })); 48 + watcher.subscribe(core); 49 + 50 + assert.deepEqual(added, ["z1", "z2"]); 51 + }); 52 + }); 53 + 54 + describe("Changed - zones_changed", () => { 55 + it("emits state_changed when state differs", () => { 56 + const core = fakeCore((cb) => { 57 + cb("Subscribed", { zones: [{ zone_id: "z1", state: "playing" }] }); 58 + cb("Changed", { 59 + zones_changed: [{ zone_id: "z1", state: "paused" }], 60 + }); 61 + }); 62 + 63 + let stateEvent; 64 + watcher.on("state_changed", (data) => { stateEvent = data; }); 65 + watcher.subscribe(core); 66 + 67 + assert.equal(stateEvent.prev_state, "playing"); 68 + assert.equal(stateEvent.zone.state, "paused"); 69 + }); 70 + 71 + it("emits track_changed when three_line differs", () => { 72 + const core = fakeCore((cb) => { 73 + cb("Subscribed", { 74 + zones: [{ 75 + zone_id: "z1", state: "playing", 76 + now_playing: { three_line: { line1: "A", line2: "T1", line3: "Al" } }, 77 + }], 78 + }); 79 + cb("Changed", { 80 + zones_changed: [{ 81 + zone_id: "z1", state: "playing", 82 + now_playing: { three_line: { line1: "B", line2: "T2", line3: "Al2" } }, 83 + }], 84 + }); 85 + }); 86 + 87 + let trackEvent; 88 + watcher.on("track_changed", (data) => { trackEvent = data; }); 89 + watcher.subscribe(core); 90 + 91 + assert.equal(trackEvent.prev_now_playing.three_line.line2, "T1"); 92 + assert.equal(trackEvent.zone.now_playing.three_line.line2, "T2"); 93 + }); 94 + 95 + it("emits seek for zone_seek_changed without mutating the stored zone", () => { 96 + const core = fakeCore((cb) => { 97 + cb("Subscribed", { 98 + zones: [{ 99 + zone_id: "z1", state: "playing", 100 + now_playing: { three_line: { line1: "A", line2: "T", line3: "Al" }, length: 300, seek_position: 0 }, 101 + }], 102 + }); 103 + cb("Changed", { 104 + zone_seek_changed: [{ zone_id: "z1", seek_position: 42 }], 105 + }); 106 + }); 107 + 108 + const seekEvents = []; 109 + watcher.on("seek", (data) => seekEvents.push(data)); 110 + watcher.subscribe(core); 111 + 112 + assert.equal(seekEvents.length, 1); 113 + assert.equal(seekEvents[0].seek_position, 42); 114 + 115 + const stored = watcher.getZone("z1"); 116 + assert.equal(stored.now_playing.seek_position, 42); 117 + }); 118 + }); 119 + 120 + describe("Changed - zones_added / zones_removed", () => { 121 + it("handles zones_added in Changed messages", () => { 122 + const core = fakeCore((cb) => { 123 + cb("Subscribed", { zones: [] }); 124 + cb("Changed", { 125 + zones_added: [{ zone_id: "z1", state: "stopped" }], 126 + }); 127 + }); 128 + 129 + let added; 130 + watcher.on("zone_added", ({ zone }) => { added = zone; }); 131 + watcher.subscribe(core); 132 + 133 + assert.equal(added.zone_id, "z1"); 134 + assert.ok(watcher.getZone("z1")); 135 + }); 136 + 137 + it("handles zones_removed in Changed messages", () => { 138 + const core = fakeCore((cb) => { 139 + cb("Subscribed", { zones: [{ zone_id: "z1", state: "stopped" }] }); 140 + cb("Changed", { 141 + zones_removed: [{ zone_id: "z1" }], 142 + }); 143 + }); 144 + 145 + let removedId; 146 + watcher.on("zone_removed", ({ zone_id }) => { removedId = zone_id; }); 147 + watcher.subscribe(core); 148 + 149 + assert.equal(removedId, "z1"); 150 + assert.equal(watcher.getZone("z1"), undefined); 151 + }); 152 + }); 153 + 154 + describe("Unsubscribed", () => { 155 + it("clears zones and emits unsubscribed", () => { 156 + const core = fakeCore((cb) => { 157 + cb("Subscribed", { zones: [{ zone_id: "z1" }] }); 158 + cb("Unsubscribed"); 159 + }); 160 + 161 + let unsub = false; 162 + watcher.on("unsubscribed", () => { unsub = true; }); 163 + watcher.subscribe(core); 164 + 165 + assert.ok(unsub); 166 + assert.equal(watcher.getAllZones().length, 0); 167 + }); 168 + }); 169 + 170 + describe("query helpers", () => { 171 + it("getZone returns the zone snapshot", () => { 172 + const core = fakeCore((cb) => { 173 + cb("Subscribed", { zones: [{ zone_id: "z1", state: "playing" }] }); 174 + }); 175 + watcher.subscribe(core); 176 + 177 + const z = watcher.getZone("z1"); 178 + assert.equal(z.zone_id, "z1"); 179 + }); 180 + 181 + it("getAllZones returns all current zones", () => { 182 + const core = fakeCore((cb) => { 183 + cb("Subscribed", { zones: [{ zone_id: "z1" }, { zone_id: "z2" }] }); 184 + }); 185 + watcher.subscribe(core); 186 + 187 + assert.equal(watcher.getAllZones().length, 2); 188 + }); 189 + }); 190 + });