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

18 kB 478 lines
1"use strict"; 2 3const { 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 */ 22const 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 */ 40class 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 */ 454function _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 */ 469function _extractBaseName(display_name) { 470 if (!display_name) return ""; 471 const first = String(display_name).split("+")[0]; 472 return first.trim().toLowerCase(); 473} 474 475module.exports = { 476 ZoneAllowlist, 477 NUM_ZONE_SLOTS, 478};