[READ-ONLY] Mirror of https://github.com/scarnecchia/roon-extension-teal-scrobbler. Roon extension that scrobbles qualifying plays to teal.fm via AT Protocol
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};