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 +19 -1
- package/README.md +3 -2
- package/dist/controller.js +1 -1
- package/dist/diagnostics.js +27 -1
- package/dist/report.js +27 -7
- package/dist/sensors/rules.js +3 -1
- package/homebridge-ui/public/app.css +1 -0
- package/homebridge-ui/public/app.js +108 -8
- package/homebridge-ui/public/model.js +81 -1
- package/homebridge-ui/server.js +2 -1
- package/package.json +1 -1
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.
|
|
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
|
package/dist/controller.js
CHANGED
|
@@ -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
|
}
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/dist/sensors/rules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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': () =>
|
|
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(
|
|
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
|
}
|
package/homebridge-ui/server.js
CHANGED
|
@@ -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.
|
|
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",
|