homebridge-plex-intermission 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.0.1] - 2026-10-23
9
+
10
+ ### Fixed
11
+
12
+ - Players added later with *Change servers, players and libraries* now reach the sensors. Sensors that watched exactly
13
+ the old choices of players, people or libraries follow the new ones; filters set by hand stay as they are.
14
+ - Sensors saved before this fix that leave out a player chosen in setup get a notice on the Sensors tab, with *Add to
15
+ the sensor* or *Leave as is*.
16
+
17
+ ### Changed
18
+
19
+ - Players with the same name, such as Plex and Infuse on one Apple TV, are shown with their app (or the start of their
20
+ ID) in sensor summaries, filters, the diagnostics file and the log.
21
+ - Saved players take Plex's current name and app when setup runs again.
22
+ - Diagnostics: the log is newest first, and *Watch live* shows the log as it happens, in local time and with names,
23
+ without downloading anything.
24
+
8
25
  ## [1.0.0] - 2026-10-23
9
26
 
10
27
  First public release.
@@ -55,5 +72,6 @@ First public release.
55
72
  Home app.
56
73
  - Lost connections to Plex are retried with their reasons logged, with a warning if Plex stays unreachable.
57
74
 
58
- [Unreleased]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.0...HEAD
75
+ [Unreleased]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.1...HEAD
76
+ [1.0.1]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.0...v1.0.1
59
77
  [1.0.0]: https://github.com/maxrogers/homebridge-plex-intermission/releases/tag/v1.0.0
package/README.md CHANGED
@@ -33,7 +33,7 @@ Intermission never controls your lights; you choose what happens in the Home app
33
33
  ratings or Plex labels.
34
34
  - **A Guide** in the settings page with the Home automations for your preset, including gentle fades, with screenshots.
35
35
  - **Diagnostics** in one file for bug reports, never with your Plex sign-in, and without titles or names unless you
36
- include them.
36
+ include them. **Watch live** shows the log as it happens, right in the settings page.
37
37
 
38
38
  ## Presets
39
39
 
@@ -92,7 +92,8 @@ Intermission talks only to plex.tv and your Plex servers, with no analytics. See
92
92
 
93
93
  - **Something didn't happen when it should have:** in the plugin's settings, open **Settings**, then **Diagnostics**,
94
94
  and download the diagnostics file soon afterwards. It includes Intermission's recent log, so you don't need to turn on
95
- extra logging first. It leaves out your Plex sign-in, and titles and names unless you include them.
95
+ extra logging first. It leaves out your Plex sign-in, and titles and names unless you include them. To see what
96
+ Intermission makes of something as you do it, press **Watch live** there instead.
96
97
  - **Webhooks:** **Settings**, then **Plex Pass and webhooks**, shows whether Plex has the webhook, whether your server's
97
98
  Webhooks setting is on, and whether Intermission is listening on its port.
98
99
  - **Sensors not in the Home app:** with the child bridge, add Intermission in the Home app with the code on the Sensors
@@ -246,7 +246,7 @@ export class Controller {
246
246
  if (config.type === 'button' || (mediaOnly && !hasMediaConditions(config.media))) {
247
247
  continue;
248
248
  }
249
- const reason = filterMismatch(config.filters, context, { player: session.playerTitle, library: session.media.librarySectionTitle })
249
+ const reason = filterMismatch(config.filters, context, { player: session.playerTitle, product: session.product, library: session.media.librarySectionTitle })
250
250
  ?? (hasMediaConditions(config.media) ? mediaMismatch(config.media, this.factsFor(session.media)) : undefined);
251
251
  this.options.log.debug(' %s: %s', config.name, reason ? `ignores this playback: ${reason}` : 'follows this playback');
252
252
  }
@@ -1,4 +1,4 @@
1
- import { appendFile, mkdir, readFile, rename, stat } from 'node:fs/promises';
1
+ import { appendFile, mkdir, open, readFile, rename, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  /*
4
4
  * Private values (titles, people, players, command lines) are wrapped in invisible markers when
@@ -147,3 +147,29 @@ export async function readDiagnostics(directory, maxLines = 6_000) {
147
147
  const lines = [...await read(PREVIOUS_FILE), ...await read(DIAGNOSTICS_FILE)];
148
148
  return lines.slice(-maxLines);
149
149
  }
150
+ /** The last `maxLines` lines of the kept log, oldest first, reading only the end of the files. */
151
+ export async function tailDiagnostics(directory, maxLines = 200, maxBytes = 64_000) {
152
+ const tail = async (name, bytes) => {
153
+ const file = await open(join(directory, name), 'r').catch(() => undefined);
154
+ if (!file) {
155
+ return [];
156
+ }
157
+ try {
158
+ const { size } = await file.stat();
159
+ const length = Math.min(size, bytes);
160
+ const buffer = Buffer.alloc(length);
161
+ await file.read(buffer, 0, length, size - length);
162
+ const lines = buffer.toString('utf8').split('\n').filter(Boolean);
163
+ // The first line is likely cut part way unless the whole file was read.
164
+ return length < size ? lines.slice(1) : lines;
165
+ }
166
+ finally {
167
+ await file.close();
168
+ }
169
+ };
170
+ let lines = await tail(DIAGNOSTICS_FILE, maxBytes);
171
+ if (lines.length < maxLines) {
172
+ lines = [...await tail(PREVIOUS_FILE, maxBytes), ...lines];
173
+ }
174
+ return lines.slice(-maxLines);
175
+ }
package/dist/report.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { commandLine, person, player, readDiagnostics, redact, scrubSecrets } from './diagnostics.js';
2
+ import { commandLine, person, player, readDiagnostics, redact, scrubSecrets, tailDiagnostics } from './diagnostics.js';
3
3
  import { hidePublicAddresses, maskAddress } from './network.js';
4
4
  import { webhookOptionsFrom } from './setup-service.js';
5
5
  const PORT_STATES = {
@@ -91,7 +91,13 @@ export async function setupReport(service, input) {
91
91
  export async function diagnosticsReport(service, input, now) {
92
92
  const config = input.config;
93
93
  const selection = (config.selection ?? {});
94
- const playerNames = new Map((selection.players ?? []).map((p) => [p.id, p.name ?? p.id]));
94
+ const players = new Map((selection.players ?? []).map((p) => [p.id, p]));
95
+ // The app and the start of the ID tell apart players with the same name.
96
+ const describePlayer = (id) => {
97
+ const found = players.get(id);
98
+ const tell = [found?.product, id.slice(0, 8)].filter(Boolean).join(', ');
99
+ return found?.name ? `${player(found.name)} (${tell})` : id;
100
+ };
95
101
  const libraryNames = new Map((selection.libraries ?? []).map((l) => [l.key, l.title ?? l.key]));
96
102
  const commands = new Map((config.commands?.list ?? []).map((c) => [c.id, c]));
97
103
  const list = (values, name, all) => {
@@ -114,7 +120,7 @@ export async function diagnosticsReport(service, input, now) {
114
120
  lines.push(sensor.type === 'button'
115
121
  ? ` pressed on: ${list(sensor.events, (e) => e, 'nothing')}`
116
122
  : ` on while: ${String(sensor.state)}${Array.isArray(sensor.except) && sensor.except.length ? `, except ${sensor.except.join(' and ')}` : ''}`);
117
- lines.push(` players: ${list(f.players, (id) => player(playerNames.get(id) ?? id), 'all')}`);
123
+ lines.push(` players: ${list(f.players, describePlayer, 'all')}`);
118
124
  lines.push(` people: ${list(f.users, (u) => person(u), 'all')}`);
119
125
  lines.push(` libraries: ${list(f.libraries, (k) => libraryNames.get(k) ?? k, 'all')}`);
120
126
  if (sensor.media && Object.keys(sensor.media).length) {
@@ -130,10 +136,24 @@ export async function diagnosticsReport(service, input, now) {
130
136
  lines.push(`${command.name ?? '?'}: ${commandLine(command.command ?? '')}`);
131
137
  }
132
138
  }
133
- const log = await readDiagnostics(service.storage.directory);
134
- lines.push('', `== Recent log (${log.length} lines, oldest first, detailed) ==`, ...(log.length ? log : ['Nothing logged yet. Restart Homebridge once, so Intermission starts keeping its log.']));
139
+ const log = (await readDiagnostics(service.storage.directory)).reverse();
140
+ lines.push('', `== Recent log (${log.length} lines, newest first, detailed) ==`, ...(log.length ? log : [NOTHING_LOGGED]));
141
+ return hidePublicAddresses(scrubSecrets(redact(lines.join('\n'), input.includeNames === true), await secretsOf(service)));
142
+ }
143
+ const NOTHING_LOGGED = 'Nothing logged yet. Restart Homebridge once, so Intermission starts keeping its log.';
144
+ /**
145
+ * The newest lines of the kept log, newest first, for watching on the settings page. Shown only on
146
+ * this machine, so names are kept; secrets are still removed.
147
+ */
148
+ export async function liveLog(service, maxLines = 200) {
149
+ const log = (await tailDiagnostics(service.storage.directory, maxLines)).reverse();
150
+ if (log.length === 0) {
151
+ return [NOTHING_LOGGED];
152
+ }
153
+ return scrubSecrets(redact(log.join('\n'), true), await secretsOf(service)).split('\n');
154
+ }
155
+ async function secretsOf(service) {
135
156
  const credentials = await service.storage.readCredentials();
136
157
  const { webhookSecret } = await service.storage.state();
137
- const secrets = [webhookSecret, credentials?.accountToken, ...Object.values(credentials?.serverTokens ?? {})].filter((s) => Boolean(s));
138
- return hidePublicAddresses(scrubSecrets(redact(lines.join('\n'), input.includeNames === true), secrets));
158
+ return [webhookSecret, credentials?.accountToken, ...Object.values(credentials?.serverTokens ?? {})].filter((s) => Boolean(s));
139
159
  }
@@ -76,7 +76,9 @@ export function mediaMatches(conditions, facts) {
76
76
  /** Why a filter leaves out this playback, or undefined when it allows it. For detailed logging. */
77
77
  export function filterMismatch(filters, context, names = {}) {
78
78
  if (filters.players.length > 0 && (context.playerId === undefined || !filters.players.includes(context.playerId))) {
79
- return `player ${playerName(names.player ?? context.playerId ?? 'unknown')} is not chosen`;
79
+ // The app or ID tells apart players with the same name, such as two apps on one Apple TV.
80
+ const tell = names.product ?? context.playerId?.slice(0, 8);
81
+ return `player ${playerName(names.player ?? context.playerId ?? 'unknown')}${names.player && tell ? ` (${tell})` : ''} is not chosen`;
80
82
  }
81
83
  if (filters.users.length > 0 && (context.user === undefined || !filters.users.includes(context.user))) {
82
84
  return `${context.user === undefined ? 'the person is unknown' : `${person(context.user)} is not chosen`}`;
@@ -162,6 +162,7 @@
162
162
  .im .im-field small { display: block; margin-top: 0.1rem; }
163
163
  .im .im-field:last-child { border-bottom: 0; }
164
164
  .im .im-report { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; white-space: pre; }
165
+ .im .im-live-log { height: 24rem; overflow: auto; }
165
166
 
166
167
  .im .im-recipe ol { padding-left: 1.2rem; }
167
168
  .im .im-recipe li { margin-bottom: 0.3rem; }
@@ -4,7 +4,7 @@ import {
4
4
  CONDITIONS, DEFAULT_WEBHOOK_PORT, EVENTS, EXCEPTIONS, CAN_EXCEPT, CREDITS_MODES, LIBRARY_KINDS, PLATFORM, PRESETS, DEFAULT_PRESET, SENSOR_TYPES, TIMING_FIELDS,
5
5
  SHAPES, RESOLUTIONS, DYNAMIC_RANGES, RATINGS,
6
6
  applyPreset, defaultSelection, creditsMode, describeFacts, groupIntact, groupPreset, hasMediaConditions, homeKitName, libraryRef, needsPlexPass, presetSensors, sensorId, usesCredits,
7
- reconcileSelection, reconcileSensors, newChildBridge, newSensor, playerRef, setTiming, summarize, timingValue,
7
+ reconcileSelection, reconcileSensors, followSelection, missingSetupPlayers, playerLabel, newChildBridge, newSensor, playerRef, setTiming, summarize, timingValue,
8
8
  } from './model.js';
9
9
 
10
10
  const root = document.getElementById('intermission');
@@ -348,9 +348,14 @@ async function loadDiscovery(next, { keepSelection = false } = {}) {
348
348
  }
349
349
 
350
350
  async function finishSetup() {
351
+ // Sensors that watched exactly the old choices follow the new ones.
352
+ const followed = followSelection(state.config.sensors, state.config.selection, state.selection);
353
+ state.config.sensors = followed.sensors;
351
354
  if (state.setupServers) {
352
355
  if (state.config.sensors.length > 0) {
353
- state.config.sensors = reconcileSensors(state.config.sensors, state.discovery, savedServerIds(), state.config.selection?.libraries ?? []);
356
+ const others = state.config.sensors.filter((s) => !followed.followedLibraries.has(s.id));
357
+ const reconciled = reconcileSensors(others, state.discovery, savedServerIds(), state.config.selection?.libraries ?? []);
358
+ state.config.sensors = state.config.sensors.map((s) => reconciled.find((r) => r.id === s.id) ?? s);
354
359
  }
355
360
  state.config.servers = state.setupServers;
356
361
  state.setupServers = undefined;
@@ -412,6 +417,7 @@ function render() {
412
417
  root.innerHTML = `<div class="im-alert im-alert-danger">Something went wrong showing this page: ${escape(error.message)}</div>
413
418
  <button class="btn btn-outline-secondary" data-action="reload">Reload</button>${footer()}`;
414
419
  }
420
+ showLiveLog();
415
421
  homebridge.fixScrollHeight?.();
416
422
  }
417
423
 
@@ -668,7 +674,7 @@ function sensorsTab() {
668
674
  ${members.map(([s, i]) => sensorCard(s, i, sel)).join('')}
669
675
  </div>`);
670
676
  });
671
- return `${pairingCard()}${webhookNotice()}
677
+ return `${pairingCard()}${webhookNotice()}${missingPlayersNotice()}
672
678
  ${parts.join('') || '<p class="im-muted">No sensors yet. Add a preset, or a sensor of your own.</p>'}
673
679
  <div class="d-flex flex-wrap gap-2 mt-2">
674
680
  <button class="btn btn-im" data-action="add" data-type="occupancy">Add sensor</button>
@@ -679,6 +685,25 @@ function sensorsTab() {
679
685
  <p class="im-muted small mt-3">Drag cards to reorder. Changes apply after you press Save and restart Homebridge. Renaming keeps your Home automations.</p>`;
680
686
  }
681
687
 
688
+ /** Offers to add players chosen in setup that sensors leave out, such as a second app on the same Apple TV. */
689
+ function missingPlayersNotice() {
690
+ const sel = selection();
691
+ const { players, sensorIds } = missingSetupPlayers(state.config.sensors, sel);
692
+ if (players.length === 0) {
693
+ return '';
694
+ }
695
+ const names = players.map((p) => playerLabel(p, sel.players)).join(', ');
696
+ const count = sensorIds.length;
697
+ return `<div class="im-card im-banner mb-3" id="im-missing-players">
698
+ <strong>${players.length === 1 ? 'A player you chose is' : 'Players you chose are'} not on ${count === 1 ? '1 sensor' : `${count} sensors`}</strong>
699
+ <p class="small mb-2 mt-1">${escape(names)} ${players.length === 1 ? 'was' : 'were'} chosen in setup, but ${count === 1 ? 'a sensor only watches' : 'these sensors only watch'} the other players, so playback there is ignored.</p>
700
+ <div class="d-flex flex-wrap gap-2">
701
+ <button class="btn btn-sm btn-im" data-action="add-missing-players">Add to ${count === 1 ? 'the sensor' : 'them'}</button>
702
+ <button class="btn btn-sm btn-outline-secondary" data-action="dismiss-missing-players">Leave as is</button>
703
+ </div>
704
+ </div>`;
705
+ }
706
+
682
707
  /** The preset list, and the "Add alongside / Replace" choice when sensors already exist. */
683
708
  function presetPanel() {
684
709
  const count = state.config.sensors.length;
@@ -750,7 +775,7 @@ function editor(sensor, sel) {
750
775
  (e) => `${EVENTS[e].label}${EVENTS[e].plexPass ? ' ★' : ''}`, (e) => noPass && EVENTS[e].plexPass)}</div>
751
776
  <div class="im-hint">★ needs Plex Pass</div>`}
752
777
  <label class="im-label">Players</label>
753
- ${filter('players', players, (p) => p.name, 'All players')}
778
+ ${filter('players', players, (p) => playerLabel(p, players), 'All players')}
754
779
  <label class="im-label">People</label>
755
780
  ${filter('users', users, (u) => u, 'All Plex Home users')}
756
781
  <label class="im-label">Libraries</label>
@@ -1012,7 +1037,7 @@ function advancedTab() {
1012
1037
  <strong>Your setup</strong>
1013
1038
  <dl class="im-facts">
1014
1039
  <dt>Plex servers</dt><dd>${escape(servers().map((s) => s.name).join(', ') || 'None')}</dd>
1015
- <dt>Players</dt><dd>${escape(sel.allPlayers ? 'All players' : sel.players.map((p) => p.name).join(', ') || 'None')}</dd>
1040
+ <dt>Players</dt><dd>${escape(sel.allPlayers ? 'All players' : sel.players.map((p) => playerLabel(p, sel.players)).join(', ') || 'None')}</dd>
1016
1041
  <dt>People</dt><dd>${escape(sel.allUsers === false ? sel.users.join(', ') : 'All Plex Home users')}</dd>
1017
1042
  <dt>Libraries</dt><dd>${escape(libraryNames)}</dd>
1018
1043
  <dt>Bridge</dt><dd>${state.config._bridge ? `Intermission's own bridge${state.pairing?.paired ? ', in the Home app' : ''}` : 'Main Homebridge bridge'}</dd>
@@ -1067,7 +1092,7 @@ function advancedTab() {
1067
1092
  ${collapsible('advanced', 'Advanced', 'Timing, logging and commands. These rarely need changing.', `
1068
1093
  ${fields}
1069
1094
  <div class="im-field">
1070
- <label for="im-detailed"><strong>Detailed logging</strong><small>Shows every Plex report, event and decision in the Homebridge log, including why each sensor follows or ignores a playback. Diagnostics includes this detail either way, so you only need it to watch along live.</small></label>
1095
+ <label for="im-detailed"><strong>Detailed logging</strong><small>Shows every Plex report, event and decision in the Homebridge log, including why each sensor follows or ignores a playback. Diagnostics and Watch live include this detail either way, so you only need it to see the detail in the Homebridge log.</small></label>
1071
1096
  <div class="form-check form-switch"><input id="im-detailed" class="form-check-input" type="checkbox" data-detailed ${state.config.detailedLogging ? 'checked' : ''}></div>
1072
1097
  </div>
1073
1098
  ${commandsSection()}`)}
@@ -1100,11 +1125,61 @@ function diagnosticsCard() {
1100
1125
  <button class="btn btn-sm btn-im" data-action="download-diagnostics">Download diagnostics</button>
1101
1126
  <button class="btn btn-sm btn-outline-secondary" data-action="copy-diagnostics">Copy</button>
1102
1127
  <button class="btn btn-sm btn-outline-secondary" data-action="show-diagnostics">${state.report ? 'Refresh' : 'Show'}</button>
1128
+ <button class="btn btn-sm btn-outline-secondary" data-action="toggle-live" aria-pressed="${state.live ? 'true' : 'false'}">${state.live ? 'Stop watching' : 'Watch live'}</button>
1103
1129
  </div>
1104
- ${state.report ? `<textarea class="form-control im-report mt-2" readonly rows="16">${escape(state.report)}</textarea>` : ''}
1130
+ ${state.live ? `<p class="im-hint mt-2 mb-1">The log as it happens, newest first, in your local time and with names, since only you see it here. Updates every 2 seconds.</p>
1131
+ <pre class="form-control im-report im-live-log mb-0" id="im-live-log" tabindex="0"></pre>` : ''}
1132
+ ${state.report && !state.live ? `<textarea class="form-control im-report mt-2" readonly rows="16">${escape(state.report)}</textarea>` : ''}
1105
1133
  </div>`;
1106
1134
  }
1107
1135
 
1136
+ /* ---------- watch live ---------- */
1137
+
1138
+ /** Fills the live log after a render, which replaces the element. */
1139
+ function showLiveLog() {
1140
+ const box = document.getElementById('im-live-log');
1141
+ if (box && state.live) {
1142
+ // Local time, since this is read as it happens; the file keeps UTC.
1143
+ box.textContent = state.live.lines.map((line) => line.replace(/^\d{4}-\d\d-\d\dT[\d:.]+Z/, (iso) => new Date(iso).toLocaleTimeString())).join('\n');
1144
+ }
1145
+ }
1146
+
1147
+ function startLive() {
1148
+ state.live = { lines: ['Loading…'], timer: undefined };
1149
+ render();
1150
+ void pollLive();
1151
+ }
1152
+
1153
+ function stopLive() {
1154
+ if (state.live) {
1155
+ clearTimeout(state.live.timer);
1156
+ state.live = undefined;
1157
+ }
1158
+ }
1159
+
1160
+ /** Reads the newest lines every 2 seconds while the live log is on screen. */
1161
+ async function pollLive() {
1162
+ const live = state.live;
1163
+ if (!live) {
1164
+ return;
1165
+ }
1166
+ try {
1167
+ live.lines = await request('/diagnostics/live', {});
1168
+ } catch (error) {
1169
+ live.lines = [`Could not read the log: ${error.message}`];
1170
+ }
1171
+ if (state.live !== live) {
1172
+ return;
1173
+ }
1174
+ if (!document.getElementById('im-live-log')) {
1175
+ // Another tab or view: stop until asked again.
1176
+ stopLive();
1177
+ return;
1178
+ }
1179
+ showLiveLog();
1180
+ live.timer = setTimeout(() => void pollLive(), POLL_MS);
1181
+ }
1182
+
1108
1183
  async function loadDiagnostics() {
1109
1184
  const includeNames = state.includeNames === true;
1110
1185
  state.report = await request('/diagnostics', { ...reportBody(), includeNames });
@@ -1369,6 +1444,20 @@ const actions = {
1369
1444
  return changed();
1370
1445
  }),
1371
1446
  'cancel-confirm': () => { state.confirming = undefined; render(); },
1447
+ 'add-missing-players': () => {
1448
+ const sel = selection();
1449
+ const { players, sensorIds } = missingSetupPlayers(state.config.sensors, sel);
1450
+ state.config.sensors = state.config.sensors.map((s) => (sensorIds.includes(s.id)
1451
+ ? { ...s, filters: { ...s.filters, players: [...new Set([...s.filters.players, ...players.map((p) => p.id)])] } }
1452
+ : s));
1453
+ return changed();
1454
+ },
1455
+ 'dismiss-missing-players': () => {
1456
+ const sel = selection();
1457
+ const { players } = missingSetupPlayers(state.config.sensors, sel);
1458
+ state.config.selection = { ...sel, dismissedPlayers: [...new Set([...(sel.dismissedPlayers ?? []), ...players.map((p) => p.id)])] };
1459
+ return changed();
1460
+ },
1372
1461
  'zoom-shot': (el) => {
1373
1462
  el.closest('.im-shot').classList.toggle('im-shot-zoom');
1374
1463
  homebridge.fixScrollHeight?.();
@@ -1490,7 +1579,18 @@ const actions = {
1490
1579
  state.webhookPlan = { add: false, remove: [] };
1491
1580
  homebridge.toast.success('The Intermission webhook was removed from your Plex account.', 'Plex webhooks');
1492
1581
  })),
1493
- 'show-diagnostics': () => busy('Collecting diagnostics…', loadDiagnostics),
1582
+ 'show-diagnostics': () => {
1583
+ stopLive();
1584
+ return busy('Collecting diagnostics…', loadDiagnostics);
1585
+ },
1586
+ 'toggle-live': () => {
1587
+ if (state.live) {
1588
+ stopLive();
1589
+ render();
1590
+ } else {
1591
+ startLive();
1592
+ }
1593
+ },
1494
1594
  'copy-diagnostics': async () => {
1495
1595
  // Text already shown, and still for the same name setting, is copied inside the click.
1496
1596
  const fresh = state.report && state.reportNames === (state.includeNames === true);
@@ -337,15 +337,89 @@ function list(names, empty) {
337
337
  return names.length <= 2 ? names.join(' or ') : `${names.slice(0, 2).join(', ')} or ${names.length - 2} more`;
338
338
  }
339
339
 
340
+ /** A product name without the "Plex for" prefix, for example "Apple TV" or "Infuse". */
341
+ const shortProduct = (product) => (product ? product.replace(/^Plex (for|Media Player for) /i, '') : undefined);
342
+
343
+ /**
344
+ * A player's name, with its app or the start of its ID added when another player in `players` has the
345
+ * same name (two apps on one Apple TV are both called "Apple TV").
346
+ */
347
+ export function playerLabel(player, players = []) {
348
+ if (!player) {
349
+ return '';
350
+ }
351
+ const twins = players.filter((p) => p.id !== player.id && p.name === player.name);
352
+ if (twins.length === 0) {
353
+ return player.name;
354
+ }
355
+ const product = shortProduct(player.product);
356
+ const tell = product && product !== player.name && !twins.some((t) => shortProduct(t.product) === product) ? product : player.id.slice(0, 8);
357
+ return `${player.name} (${tell})`;
358
+ }
359
+
360
+ /**
361
+ * Brings sensors along when the setup choices change: a sensor whose players, people or libraries
362
+ * were exactly the old choices gets the new ones; filters set by hand stay as they are. Returns the
363
+ * sensors, and the IDs of those whose libraries followed.
364
+ */
365
+ export function followSelection(sensors, before, after) {
366
+ if (!before) {
367
+ return { sensors, followedLibraries: new Set(), changed: 0 };
368
+ }
369
+ const was = filtersFrom(before);
370
+ const now = filtersFrom(after);
371
+ const same = (a = [], b = []) => a.length === b.length && a.every((v) => b.includes(v));
372
+ const followedLibraries = new Set();
373
+ let changed = 0;
374
+ const result = sensors.map((sensor) => {
375
+ const filters = { ...sensor.filters };
376
+ let touched = false;
377
+ for (const key of ['players', 'users', 'libraries']) {
378
+ if (same(filters[key], was[key]) && !same(was[key], now[key])) {
379
+ filters[key] = [...now[key]];
380
+ touched = true;
381
+ if (key === 'libraries') {
382
+ followedLibraries.add(sensor.id);
383
+ }
384
+ }
385
+ }
386
+ changed += touched ? 1 : 0;
387
+ return touched ? { ...sensor, filters } : sensor;
388
+ });
389
+ return { sensors: result, followedLibraries, changed };
390
+ }
391
+
392
+ /**
393
+ * Players chosen in setup that some sensors leave out, though those sensors otherwise watch only
394
+ * setup players (so they look like they were meant to follow setup). Players in `dismissedPlayers` are
395
+ * left out. Returns the players and sensor IDs.
396
+ */
397
+ export function missingSetupPlayers(sensors, selection) {
398
+ const dismissed = selection?.dismissedPlayers ?? [];
399
+ const all = selection?.allPlayers ? [] : (selection?.players ?? []);
400
+ const ids = all.map((p) => p.id);
401
+ const chosen = all.filter((p) => !dismissed.includes(p.id));
402
+ const affected = sensors.filter((s) => {
403
+ const players = s.filters?.players ?? [];
404
+ return players.length > 0 && players.every((id) => ids.includes(id)) && chosen.some((p) => !players.includes(p.id));
405
+ });
406
+ const missing = chosen.filter((p) => affected.some((s) => !s.filters.players.includes(p.id)));
407
+ return { players: missing, sensorIds: affected.map((s) => s.id) };
408
+ }
409
+
340
410
  /** One plain-language line describing what a sensor does. */
341
411
  export function summarize(sensor, selection = {}) {
342
412
  const name = (items, id, key, field = 'id') => items?.find((item) => item[field] === id)?.[key] ?? id;
413
+ const player = (id) => {
414
+ const found = selection.players?.find((p) => p.id === id);
415
+ return found ? playerLabel(found, selection.players) : id;
416
+ };
343
417
  const except = CAN_EXCEPT.includes(sensor.state) ? (sensor.except ?? []) : [];
344
418
  const when = SENSOR_TYPES[sensor.type]?.state
345
419
  ? `On while ${({ idle: 'nothing is playing', active: 'playing or paused', credits: 'in the credits', intro: 'in the intro' })[sensor.state] ?? (CONDITIONS[sensor.state] ?? CONDITIONS.playing).toLowerCase()}${except.length ? `, except during ${except.map((e) => EXCEPTIONS[e]?.label ?? e).join(' and ')}` : ''}`
346
420
  : `Pressed when ${list((sensor.events ?? []).map((e) => EVENTS[e]?.phrase ?? e), 'nothing (choose an event)')}`;
347
421
  const f = sensor.filters ?? emptyFilters();
348
- const where = list(f.players.map((id) => name(selection.players, id, 'name')), 'any player');
422
+ const where = list(f.players.map(player), 'any player');
349
423
  const what = list(f.libraries.map((key) => name(selection.libraries, key, 'title', 'key')), 'anything');
350
424
  const who = list(f.users, 'anyone');
351
425
  const media = SENSOR_TYPES[sensor.type]?.state ? mediaPhrases(sensor.media) : [];
@@ -393,8 +467,14 @@ export function reconcileSelection(selection, discovery, previousServerIds = [])
393
467
  const keys = new Set(available.map((l) => l.key));
394
468
  const added = available.filter((l) => !previousServerIds.includes(l.serverId) && LIBRARY_KINDS[l.kind]?.preselect);
395
469
  const kept = (selection.libraries ?? []).filter((l) => keys.has(l.key));
470
+ // Saved players take Plex's current name and app, so same-name players can be told apart.
471
+ const players = (selection.players ?? []).map((p) => {
472
+ const found = discovery.players?.find((d) => d.id === p.id);
473
+ return found ? playerRef(found) : p;
474
+ });
396
475
  return {
397
476
  ...selection,
477
+ players,
398
478
  libraries: selection.allLibraries ? [] : [...kept, ...added.filter((l) => !kept.some((k) => k.key === l.key))],
399
479
  };
400
480
  }
@@ -6,7 +6,7 @@ import qrcode from 'qrcode-generator';
6
6
 
7
7
  import { CommandRunner } from '../dist/commands.js';
8
8
  import { PlexHttpError } from '../dist/plex/server.js';
9
- import { diagnosticsReport } from '../dist/report.js';
9
+ import { diagnosticsReport, liveLog } from '../dist/report.js';
10
10
  import { SetupError, SetupService, webhookOptionsFrom } from '../dist/setup-service.js';
11
11
  import { Storage } from '../dist/storage.js';
12
12
 
@@ -62,6 +62,7 @@ class IntermissionUiServer extends HomebridgePluginUiServer {
62
62
  timeZone: typeof body.timeZone === 'string' ? body.timeZone.slice(0, 80) : undefined,
63
63
  includeNames: body.includeNames === true,
64
64
  }, new Date()));
65
+ this.handle('/diagnostics/live', () => liveLog(service));
65
66
  this.ready();
66
67
  }
67
68
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "homebridge-plex-intermission",
3
3
  "displayName": "Intermission for Plex",
4
4
  "type": "module",
5
- "version": "1.0.0",
5
+ "version": "1.0.1",
6
6
  "description": "Apple Home sensors for every moment of Plex playback: playing, paused, credits and nothing playing. Timed to Plex's credits markers, instant without polling.",
7
7
  "author": "maxrogers",
8
8
  "license": "MIT",