homebridge-plex-intermission 1.0.0 → 1.1.0
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 +56 -1
- package/README.md +11 -5
- package/dist/config.js +33 -2
- package/dist/controller.js +85 -6
- package/dist/diagnostics.js +27 -1
- package/dist/platform.js +4 -0
- package/dist/players.js +163 -0
- package/dist/plex/convert.js +1 -0
- package/dist/plex/discovery.js +1 -0
- package/dist/report.js +78 -7
- package/dist/sensors/rules.js +61 -6
- package/dist/setup-service.js +16 -1
- package/dist/storage.js +8 -0
- package/homebridge-ui/public/app.css +7 -0
- package/homebridge-ui/public/app.js +332 -20
- package/homebridge-ui/public/guide.js +1 -1
- package/homebridge-ui/public/model.js +277 -10
- package/homebridge-ui/server.js +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ All notable changes to this project are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-11-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Players are chosen by device. Choosing the Apple TV includes every Plex app on it, including apps added or reinstalled
|
|
13
|
+
later, while it plays on your home network. *Choose apps* lists the apps seen on a device, to leave one out.
|
|
14
|
+
- Two devices that report the same name (apps see "Apple TV" for every Apple TV) are noticed, with a log warning, and
|
|
15
|
+
setup offers *This one* to keep only the one in the room. You can name devices.
|
|
16
|
+
- Diagnostics: a Network check saying whether Plex sees players' real addresses and marks them local, devices and apps
|
|
17
|
+
left out on each sensor, and each player's platform.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Choices made app by app move to devices when the settings page opens. Apps that weren't chosen are left out, so
|
|
22
|
+
nothing new changes the lights; press Save to keep it.
|
|
23
|
+
- A player is at home when Plex marks it local or its address is private, so Plex in a Docker bridge network without
|
|
24
|
+
LAN Networks set still works.
|
|
25
|
+
- Addresses shared by players on different platforms are never used to tell devices apart or follow new IDs.
|
|
26
|
+
|
|
27
|
+
## [1.0.2] - 2026-11-06
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A player that Plex gives a new ID, after its app is reinstalled or signed in again, keeps changing the lights. When
|
|
32
|
+
its name, app and LAN address match a chosen player, the sensors follow it as that player and the log says so.
|
|
33
|
+
- A player that only shares a chosen player's name gets a warning in the log instead of being silently ignored.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- The Sensors tab offers to add a player with a new ID to the sensors that watch the player it replaces, or to leave it
|
|
38
|
+
off.
|
|
39
|
+
- Setup: players that share a name show when each last played and the start of its ID, and apps Plex lists without a
|
|
40
|
+
name show the app Intermission saw them play as.
|
|
41
|
+
- Diagnostics lists the players seen playing, with their app, last play and whether they are chosen or followed.
|
|
42
|
+
|
|
43
|
+
## [1.0.1] - 2026-10-23
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Players added later with *Change servers, players and libraries* now reach the sensors. Sensors that watched exactly
|
|
48
|
+
the old choices of players, people or libraries follow the new ones; filters set by hand stay as they are.
|
|
49
|
+
- Sensors saved before this fix that leave out a player chosen in setup get a notice on the Sensors tab, with *Add to
|
|
50
|
+
the sensor* or *Leave as is*.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Players with the same name, such as Plex and Infuse on one Apple TV, are shown with their app (or the start of their
|
|
55
|
+
ID) in sensor summaries, filters, the diagnostics file and the log.
|
|
56
|
+
- Saved players take Plex's current name and app when setup runs again.
|
|
57
|
+
- Diagnostics: the log is newest first, and *Watch live* shows the log as it happens, in local time and with names,
|
|
58
|
+
without downloading anything.
|
|
59
|
+
|
|
8
60
|
## [1.0.0] - 2026-10-23
|
|
9
61
|
|
|
10
62
|
First public release.
|
|
@@ -55,5 +107,8 @@ First public release.
|
|
|
55
107
|
Home app.
|
|
56
108
|
- Lost connections to Plex are retried with their reasons logged, with a warning if Plex stays unreachable.
|
|
57
109
|
|
|
58
|
-
[Unreleased]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.
|
|
110
|
+
[Unreleased]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.1.0...HEAD
|
|
111
|
+
[1.1.0]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.2...v1.1.0
|
|
112
|
+
[1.0.2]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.1...v1.0.2
|
|
113
|
+
[1.0.1]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.0...v1.0.1
|
|
59
114
|
[1.0.0]: https://github.com/maxrogers/homebridge-plex-intermission/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ Most Plex plugins give Apple Home a single "playing" sensor. Intermission adds:
|
|
|
15
15
|
- **Credits timing.** Lights come up when Plex's credits markers say the credits start, never before, re-timed when you skip.
|
|
16
16
|
- **A sensor for each moment.** Playing, paused, credits and nothing playing, so each gets its own scene.
|
|
17
17
|
- **Instant, without polling.** Plex pushes every change, and it works without Plex Pass too.
|
|
18
|
-
- **Sign in with Plex.**
|
|
18
|
+
- **Sign in with Plex.** Devices and libraries come from a list; no tokens or IDs to copy.
|
|
19
|
+
- **Choose the device, not the app.** Pick the Apple TV in the room and every Plex app on it counts, including apps
|
|
20
|
+
added or reinstalled later. Leave out an app on it if you like.
|
|
19
21
|
|
|
20
22
|
I build things I need for myself, and sometimes I share them. This [Homebridge](https://homebridge.io) plugin is one
|
|
21
23
|
of those: I wanted the room to react to movie night using nothing but Apple Home.
|
|
@@ -29,11 +31,11 @@ Intermission never controls your lights; you choose what happens in the Home app
|
|
|
29
31
|
- **Mid-credits scenes** can dim the lights again, and short pauses or skip bursts never flicker them.
|
|
30
32
|
- **Sensors, not just buttons.** Home can always read a sensor, so a hub restart or a Wi-Fi drop never leaves your
|
|
31
33
|
lights in the wrong state. Buttons cover one-off events such as "Marked as watched".
|
|
32
|
-
- **Filters** by
|
|
34
|
+
- **Filters** by device (or app), person and library (including Live TV), and by the media itself: wide movies, 4K, HDR,
|
|
33
35
|
ratings or Plex labels.
|
|
34
36
|
- **A Guide** in the settings page with the Home automations for your preset, including gentle fades, with screenshots.
|
|
35
37
|
- **Diagnostics** in one file for bug reports, never with your Plex sign-in, and without titles or names unless you
|
|
36
|
-
include them.
|
|
38
|
+
include them. **Watch live** shows the log as it happens, right in the settings page.
|
|
37
39
|
|
|
38
40
|
## Presets
|
|
39
41
|
|
|
@@ -50,7 +52,7 @@ fades made with Convert To Shortcut.
|
|
|
50
52
|
|
|
51
53
|
1. In the Homebridge UI, open **Plugins**, search for **Intermission for Plex** and install it.
|
|
52
54
|
2. Open the plugin's settings and **Sign in with Plex**.
|
|
53
|
-
3. Choose your servers, then your
|
|
55
|
+
3. Choose your servers, then your devices, people and libraries.
|
|
54
56
|
4. Choose a preset and save. Intermission runs on its own child bridge by default; restart Homebridge, then add it in
|
|
55
57
|
the Home app with the code shown on the Sensors tab.
|
|
56
58
|
5. Make the automations from the Guide tab.
|
|
@@ -92,9 +94,13 @@ Intermission talks only to plex.tv and your Plex servers, with no analytics. See
|
|
|
92
94
|
|
|
93
95
|
- **Something didn't happen when it should have:** in the plugin's settings, open **Settings**, then **Diagnostics**,
|
|
94
96
|
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.
|
|
97
|
+
extra logging first. It leaves out your Plex sign-in, and titles and names unless you include them. To see what
|
|
98
|
+
Intermission makes of something as you do it, press **Watch live** there instead.
|
|
96
99
|
- **Webhooks:** **Settings**, then **Plex Pass and webhooks**, shows whether Plex has the webhook, whether your server's
|
|
97
100
|
Webhooks setting is on, and whether Intermission is listening on its port.
|
|
101
|
+
- **A device's playback is ignored:** a device counts while it plays on your home network. Diagnostics, Network check
|
|
102
|
+
says whether Plex reports real addresses and marks your players local. Apps report the device's model name ("Apple
|
|
103
|
+
TV"), so two Apple TVs share one; Intermission notices and setup offers **This one** to keep only the one in the room.
|
|
98
104
|
- **Sensors not in the Home app:** with the child bridge, add Intermission in the Home app with the code on the Sensors
|
|
99
105
|
tab after restarting Homebridge.
|
|
100
106
|
|
package/dist/config.js
CHANGED
|
@@ -74,11 +74,13 @@ export function parseConfig(raw, log) {
|
|
|
74
74
|
// 0 turns it off, otherwise 1 to 30 minutes.
|
|
75
75
|
const safetyResyncMs = timing.safetyResync === 0 ? 0 : minutes(timing, 'safetyResync', 5 * 60_000, 1, 30);
|
|
76
76
|
const port = webhook.port;
|
|
77
|
+
const devices = parseDevices(config.devices);
|
|
77
78
|
return {
|
|
78
79
|
name: asString(config.name) ?? 'Intermission',
|
|
79
80
|
setupId: asString(config.setupId),
|
|
80
81
|
servers: parseServers(config.servers),
|
|
81
|
-
|
|
82
|
+
devices,
|
|
83
|
+
sensors: parseSensors(config.sensors, log, devices),
|
|
82
84
|
webhook: {
|
|
83
85
|
enabled: webhook.enabled !== false,
|
|
84
86
|
port: typeof port === 'number' && Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_WEBHOOK_PORT,
|
|
@@ -88,8 +90,21 @@ export function parseConfig(raw, log) {
|
|
|
88
90
|
safetyResyncMs,
|
|
89
91
|
detailedLogging: config.detailedLogging === true,
|
|
90
92
|
commands: parseCommands(config.commands),
|
|
93
|
+
...parseSelection(config.selection),
|
|
91
94
|
};
|
|
92
95
|
}
|
|
96
|
+
function parseSelection(value) {
|
|
97
|
+
const selection = asRecord(value);
|
|
98
|
+
const chosenPlayers = {};
|
|
99
|
+
for (const entry of Array.isArray(selection.players) ? selection.players : []) {
|
|
100
|
+
const player = asRecord(entry);
|
|
101
|
+
const id = asString(player.id);
|
|
102
|
+
if (id) {
|
|
103
|
+
chosenPlayers[id] = { name: asString(player.name), product: asString(player.product) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { chosenPlayers, ignoredPlayers: asStrings(selection.dismissedPlayers) };
|
|
107
|
+
}
|
|
93
108
|
function parseCommands(value) {
|
|
94
109
|
const section = asRecord(value);
|
|
95
110
|
if (section.enabled !== true || !Array.isArray(section.list)) {
|
|
@@ -141,7 +156,20 @@ function parseMedia(value) {
|
|
|
141
156
|
export function hasMediaConditions(conditions) {
|
|
142
157
|
return Object.keys(conditions).length > 0;
|
|
143
158
|
}
|
|
144
|
-
function
|
|
159
|
+
function parseDevices(value) {
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
return (Array.isArray(value) ? value : []).flatMap((entry) => {
|
|
162
|
+
const device = asRecord(entry);
|
|
163
|
+
const id = asString(device.id);
|
|
164
|
+
const match = asString(device.match);
|
|
165
|
+
if (!id || !match || seen.has(id)) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
seen.add(id);
|
|
169
|
+
return [{ id, name: asString(device.name) ?? match, match, apps: asStrings(device.apps) }];
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function parseSensors(value, log, devices = []) {
|
|
145
173
|
if (!Array.isArray(value)) {
|
|
146
174
|
return [];
|
|
147
175
|
}
|
|
@@ -172,6 +200,9 @@ function parseSensors(value, log) {
|
|
|
172
200
|
},
|
|
173
201
|
filters: {
|
|
174
202
|
players: asStrings(filters.players),
|
|
203
|
+
// An unknown device stays, matching nothing, so the filter never widens to every player.
|
|
204
|
+
devices: asStrings(filters.devices).map((deviceId) => devices.find((d) => d.id === deviceId) ?? { id: deviceId, name: deviceId, apps: [] }),
|
|
205
|
+
exceptPlayers: asStrings(filters.exceptPlayers),
|
|
175
206
|
users: asStrings(filters.users),
|
|
176
207
|
libraries: asStrings(filters.libraries),
|
|
177
208
|
},
|
package/dist/controller.js
CHANGED
|
@@ -4,7 +4,7 @@ import { webhookFromPayload } from './plex/convert.js';
|
|
|
4
4
|
import { PlexHttpError } from './plex/server.js';
|
|
5
5
|
import { person as markPerson, player as markPlayer, title as markTitle } from './diagnostics.js';
|
|
6
6
|
import { describe } from './errors.js';
|
|
7
|
-
import { contextOf, filterMismatch, matchesFilters, mediaMatches, mediaMismatch, stateHolds } from './sensors/rules.js';
|
|
7
|
+
import { contextOf, filterMismatch, isHome, matchesFilters, onDevice, mediaMatches, mediaMismatch, stateHolds } from './sensors/rules.js';
|
|
8
8
|
const WEBHOOK_ONLY = new Set(WEBHOOK_EVENTS);
|
|
9
9
|
/** How long sensors keep their state from before a restart when Plex cannot be read. */
|
|
10
10
|
const STARTUP_GRACE_MS = 20_000;
|
|
@@ -30,6 +30,14 @@ export class Controller {
|
|
|
30
30
|
/** Media details by rating key: a value once read, or null while reading or when Plex had none. */
|
|
31
31
|
facts = new Map();
|
|
32
32
|
needsFacts;
|
|
33
|
+
/** Every player some sensor is limited to. Empty when every sensor takes all players. */
|
|
34
|
+
chosenPlayers;
|
|
35
|
+
/** Apps pinned to devices that share their name with another device. */
|
|
36
|
+
pinnedApps;
|
|
37
|
+
/** Player names chosen as devices (lower case). */
|
|
38
|
+
deviceNames;
|
|
39
|
+
/** Unwatched players already pointed out in the log this run. */
|
|
40
|
+
pointedOut = new Set();
|
|
33
41
|
constructor(options) {
|
|
34
42
|
this.options = options;
|
|
35
43
|
this.engine = new PlaybackEngine({
|
|
@@ -39,6 +47,10 @@ export class Controller {
|
|
|
39
47
|
getMarkers: (ratingKey) => this.call(() => options.getMarkers(ratingKey), []),
|
|
40
48
|
hasCredits: options.hasCredits,
|
|
41
49
|
}, options.config.engine);
|
|
50
|
+
const devices = options.sensors.flatMap((s) => s.config.filters.devices ?? []);
|
|
51
|
+
this.pinnedApps = new Set(devices.flatMap((d) => d.apps));
|
|
52
|
+
this.chosenPlayers = new Set([...options.sensors.flatMap((s) => s.config.filters.players), ...this.pinnedApps]);
|
|
53
|
+
this.deviceNames = new Set(devices.filter((d) => d.apps.length === 0 && d.match).map((d) => d.match.trim().toLowerCase()));
|
|
42
54
|
this.needsFacts = options.getFacts !== undefined && options.sensors.some((s) => s.config.type !== 'button' && hasMediaConditions(s.config.media));
|
|
43
55
|
this.engine.on('event', (event) => this.onEngineEvent(event));
|
|
44
56
|
options.stream.on('playing', (notification) => this.engine.handleNotification(notification));
|
|
@@ -134,6 +146,7 @@ export class Controller {
|
|
|
134
146
|
log.debug('%s: %s on %s at %ds', event.type, markTitle(event.session.media.title), markPlayer(event.session.playerTitle ?? event.playerId), Math.round(event.positionMs / 1000));
|
|
135
147
|
if (event.type === 'play') {
|
|
136
148
|
this.explainSensors(event.session);
|
|
149
|
+
void this.learnPlayer(event.session);
|
|
137
150
|
}
|
|
138
151
|
// States first: a button press is delivered to the hub at once and flushes queued state changes
|
|
139
152
|
// with it, so a shortcut started by the press already sees the new states.
|
|
@@ -143,7 +156,7 @@ export class Controller {
|
|
|
143
156
|
else {
|
|
144
157
|
this.becomeReady();
|
|
145
158
|
}
|
|
146
|
-
this.pressButtons(event.type,
|
|
159
|
+
this.pressButtons(event.type, this.context(event.session), event.session);
|
|
147
160
|
if (!this.options.stream.isConnected) {
|
|
148
161
|
this.scheduleResync();
|
|
149
162
|
}
|
|
@@ -167,7 +180,7 @@ export class Controller {
|
|
|
167
180
|
}
|
|
168
181
|
}
|
|
169
182
|
webhookContext(webhook) {
|
|
170
|
-
const context =
|
|
183
|
+
const context = this.context(webhook.session, webhook.media);
|
|
171
184
|
return { ...context, user: context.user ?? webhook.user };
|
|
172
185
|
}
|
|
173
186
|
pressButtons(event, context, session) {
|
|
@@ -191,7 +204,7 @@ export class Controller {
|
|
|
191
204
|
continue;
|
|
192
205
|
}
|
|
193
206
|
const matching = snapshots.filter((snapshot) => snapshot.session !== undefined
|
|
194
|
-
&& matchesFilters(sensor.config.filters,
|
|
207
|
+
&& matchesFilters(sensor.config.filters, this.context(snapshot.session))
|
|
195
208
|
&& (!hasMediaConditions(sensor.config.media) || mediaMatches(sensor.config.media, this.factsFor(snapshot.session.media))));
|
|
196
209
|
// "Nothing playing" is on while no matching player is busy; the rest are on while any player meets them.
|
|
197
210
|
const holding = matching.find((snapshot) => stateHolds(condition, snapshot, sensor.config.except));
|
|
@@ -240,17 +253,82 @@ export class Controller {
|
|
|
240
253
|
* Logged when an item starts, and again for media conditions once the item's details are read.
|
|
241
254
|
*/
|
|
242
255
|
explainSensors(session, mediaOnly = false) {
|
|
243
|
-
const context =
|
|
256
|
+
const context = this.context(session);
|
|
244
257
|
for (const sensor of this.options.sensors) {
|
|
245
258
|
const config = sensor.config;
|
|
246
259
|
if (config.type === 'button' || (mediaOnly && !hasMediaConditions(config.media))) {
|
|
247
260
|
continue;
|
|
248
261
|
}
|
|
249
|
-
const reason = filterMismatch(config.filters, context, { player: session.playerTitle, library: session.media.librarySectionTitle })
|
|
262
|
+
const reason = filterMismatch(config.filters, context, { player: session.playerTitle, product: session.product, library: session.media.librarySectionTitle })
|
|
250
263
|
?? (hasMediaConditions(config.media) ? mediaMismatch(config.media, this.factsFor(session.media)) : undefined);
|
|
251
264
|
this.options.log.debug(' %s: %s', config.name, reason ? `ignores this playback: ${reason}` : 'follows this playback');
|
|
252
265
|
}
|
|
253
266
|
}
|
|
267
|
+
/** What sensor filters match against, with the chosen players a new player ID is followed as. */
|
|
268
|
+
context(session, media) {
|
|
269
|
+
const id = session?.playerId;
|
|
270
|
+
const book = this.options.players;
|
|
271
|
+
const known = id ? book?.get(id) : undefined;
|
|
272
|
+
return contextOf(session, media, this.options.server.id, {
|
|
273
|
+
followedAs: id && !this.options.config.ignoredPlayers.includes(id) ? book?.followedAs(id) : undefined,
|
|
274
|
+
playerName: session?.playerTitle ?? known?.name,
|
|
275
|
+
home: isHome(session) ?? isHome(known),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Records a player that started playing, with its LAN address from the server's session list.
|
|
280
|
+
* A new Plex ID that matches a chosen app is followed as it (see PlayerBook); one that only looks
|
|
281
|
+
* like a chosen app, and a name that turns out to belong to two devices, are pointed out once.
|
|
282
|
+
*/
|
|
283
|
+
async learnPlayer(session) {
|
|
284
|
+
const { players: book, log, config } = this.options;
|
|
285
|
+
if (!book) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const listed = session.address ? undefined : await this.call(() => this.options.plex.session(session.playerId, ''), undefined);
|
|
289
|
+
const player = {
|
|
290
|
+
...session,
|
|
291
|
+
playerTitle: session.playerTitle ?? listed?.playerTitle,
|
|
292
|
+
product: session.product ?? listed?.product,
|
|
293
|
+
platform: session.platform ?? listed?.platform,
|
|
294
|
+
address: session.address ?? listed?.address,
|
|
295
|
+
local: session.local ?? listed?.local,
|
|
296
|
+
};
|
|
297
|
+
const id = player.playerId;
|
|
298
|
+
const { followed, newlyShared } = book.record(player, this.chosenPlayers, { ignored: config.ignoredPlayers, pinned: this.pinnedApps });
|
|
299
|
+
const label = `${markPlayer(player.playerTitle ?? id)} (${[player.product, id.slice(0, 8)].filter(Boolean).join(', ')})`;
|
|
300
|
+
// What was unknown when play started (the name or whether it is at home) may be known now.
|
|
301
|
+
this.refreshStates();
|
|
302
|
+
if (newlyShared && this.deviceNames.has((player.playerTitle ?? '').trim().toLowerCase())) {
|
|
303
|
+
log.warn('Two devices report the player name %s: it played from one address, then another, then the first again. '
|
|
304
|
+
+ 'Both change the lights. To limit it to one, open the plugin settings and choose This one on that device.', markPlayer(player.playerTitle));
|
|
305
|
+
}
|
|
306
|
+
if (followed.length > 0) {
|
|
307
|
+
log.info('%s has a new Plex ID. It has the same name and address as a chosen app, so the sensors follow it. '
|
|
308
|
+
+ 'To make this permanent, open the plugin settings and add it to the sensors.', label);
|
|
309
|
+
this.explainSensors(player);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (this.chosenPlayers.size === 0 || this.chosenPlayers.has(id) || this.context(player).followedAs
|
|
313
|
+
|| config.ignoredPlayers.includes(id) || this.pointedOut.has(id) || this.onAnyDevice(player)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const lookalike = [...this.chosenPlayers].some((chosenId) => {
|
|
317
|
+
const known = { ...config.chosenPlayers[chosenId], ...book.get(chosenId) };
|
|
318
|
+
return sameText(known.name, player.playerTitle)
|
|
319
|
+
&& (this.pinnedApps.has(chosenId) || !known.product || !player.product || sameText(known.product, player.product));
|
|
320
|
+
});
|
|
321
|
+
if (lookalike) {
|
|
322
|
+
this.pointedOut.add(id);
|
|
323
|
+
log.warn('%s is playing, but no sensor watches it, though a chosen player has the same name. If Plex gave that player '
|
|
324
|
+
+ 'a new ID (after reinstalling or signing in to the app again), open the plugin settings and add it to the sensors.', label);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Whether any sensor's devices include this player, by name on the home network. */
|
|
328
|
+
onAnyDevice(session) {
|
|
329
|
+
const context = this.context(session);
|
|
330
|
+
return this.options.sensors.some((s) => (s.config.filters.devices ?? []).some((device) => onDevice(device, context)));
|
|
331
|
+
}
|
|
254
332
|
scheduleResync() {
|
|
255
333
|
const interval = this.options.config.safetyResyncMs;
|
|
256
334
|
if (interval <= 0 || this.resyncTimer || this.stopped) {
|
|
@@ -293,3 +371,4 @@ export class Controller {
|
|
|
293
371
|
this.options.log.warn('%s: %s', context, describe(error));
|
|
294
372
|
}
|
|
295
373
|
}
|
|
374
|
+
const sameText = (a, b) => a !== undefined && b !== undefined && a.trim().toLowerCase() === b.trim().toLowerCase();
|
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/platform.js
CHANGED
|
@@ -18,6 +18,7 @@ import { configFingerprint } from './runtime.js';
|
|
|
18
18
|
import { SensorAccessory } from './sensors/sensor-accessory.js';
|
|
19
19
|
import { CommandHooks, SensorHub } from './sensors/sensor-hub.js';
|
|
20
20
|
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
21
|
+
import { PlayerBook } from './players.js';
|
|
21
22
|
import { serverToken, Storage } from './storage.js';
|
|
22
23
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
24
|
export class IntermissionPlatform {
|
|
@@ -102,6 +103,8 @@ export class IntermissionPlatform {
|
|
|
102
103
|
}
|
|
103
104
|
this.log.debug('Settings: %s.', describeSettings(this.settings));
|
|
104
105
|
const plexServers = servers.map((server) => new PlexServer({ baseUrl: server.url, token: serverToken(credentials, server.id), clientIdentifier: state.clientIdentifier }));
|
|
106
|
+
const players = new PlayerBook(storage, (message) => this.log.debug(message));
|
|
107
|
+
await players.load();
|
|
105
108
|
const libraryTypes = new LibraryTypes(servers.map((server, index) => ({ serverId: server.id, libraries: () => plexServers[index].libraries() })));
|
|
106
109
|
await libraryTypes.refresh();
|
|
107
110
|
servers.forEach((server, index) => {
|
|
@@ -119,6 +122,7 @@ export class IntermissionPlatform {
|
|
|
119
122
|
hasCredits: (media) => libraryTypes.hasCredits(server.id, media),
|
|
120
123
|
getFacts: (ratingKey) => plex.facts(ratingKey),
|
|
121
124
|
sensors: hubs.map((hub) => hub.output(index)),
|
|
125
|
+
players,
|
|
122
126
|
});
|
|
123
127
|
this.controllers.push(controller);
|
|
124
128
|
controller.start();
|
package/dist/players.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe } from './errors.js';
|
|
2
|
+
/** Seen players are forgotten after this long without playing. */
|
|
3
|
+
const FORGET_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
|
|
4
|
+
/** Sightings kept per name, and how far back they count. */
|
|
5
|
+
const SIGHTINGS_KEPT = 12;
|
|
6
|
+
const SIGHTINGS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const key = (name) => name.trim().toLowerCase();
|
|
8
|
+
const same = (a, b) => a !== undefined && b !== undefined && key(a) === key(b);
|
|
9
|
+
/**
|
|
10
|
+
* Remembers players as they play: name, app, LAN address and whether Plex calls them local.
|
|
11
|
+
*
|
|
12
|
+
* - Plex gives an app a new ID when it is reinstalled or signed in again, and some filters hold IDs.
|
|
13
|
+
* A new ID with the same name, app and address as a chosen app is followed as it; one with the
|
|
14
|
+
* same name and address as an app pinned to a device is followed as that app, whatever the app.
|
|
15
|
+
* - Addresses are only evidence: an address shared by players on different platforms is not
|
|
16
|
+
* trusted (Plex behind Docker Desktop or rootless Docker sees every player at one address).
|
|
17
|
+
* - A name seen at one address, then another, then the first again belongs to two devices; an
|
|
18
|
+
* address change only moves it once.
|
|
19
|
+
*
|
|
20
|
+
* Kept in `players.json`; the settings page reads it too.
|
|
21
|
+
*/
|
|
22
|
+
export class PlayerBook {
|
|
23
|
+
storage;
|
|
24
|
+
onError;
|
|
25
|
+
records = emptyRecords();
|
|
26
|
+
saving = Promise.resolve();
|
|
27
|
+
constructor(storage, onError = () => undefined) {
|
|
28
|
+
this.storage = storage;
|
|
29
|
+
this.onError = onError;
|
|
30
|
+
}
|
|
31
|
+
async load(now = new Date()) {
|
|
32
|
+
this.records = parseRecords(await this.storage.readPlayers().catch(() => undefined));
|
|
33
|
+
const cutoff = now.getTime() - FORGET_AFTER_MS;
|
|
34
|
+
for (const [id, player] of Object.entries(this.records.seen)) {
|
|
35
|
+
if (Date.parse(player.lastPlayedAt) < cutoff) {
|
|
36
|
+
delete this.records.seen[id];
|
|
37
|
+
delete this.records.followed[id];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The chosen players a player is followed as. */
|
|
42
|
+
followedAs(playerId) {
|
|
43
|
+
return playerId ? this.records.followed[playerId] ?? [] : [];
|
|
44
|
+
}
|
|
45
|
+
get(playerId) {
|
|
46
|
+
return this.records.seen[playerId];
|
|
47
|
+
}
|
|
48
|
+
/** Whether players on one device can be told apart from others by this address. */
|
|
49
|
+
trusted(address) {
|
|
50
|
+
if (!address) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const here = Object.values(this.records.seen).filter((p) => p.address === address);
|
|
54
|
+
const platforms = new Set(here.flatMap((p) => (p.platform ? [key(p.platform)] : [])));
|
|
55
|
+
const names = new Set(here.flatMap((p) => (p.name ? [key(p.name)] : [])));
|
|
56
|
+
return platforms.size <= 1 && names.size <= 2;
|
|
57
|
+
}
|
|
58
|
+
/** Whether two devices have been seen reporting this name. */
|
|
59
|
+
isShared(name) {
|
|
60
|
+
return name !== undefined && this.records.sharedNames[key(name)] !== undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Records a player that started playing. When it is not chosen, not left out and not yet
|
|
64
|
+
* followed, returns the chosen players it is followed as from now on (see the class comment).
|
|
65
|
+
* `pinned` holds apps pinned to devices, which need the same name and address but not the same app.
|
|
66
|
+
*/
|
|
67
|
+
record(session, chosen, options = {}, now = new Date()) {
|
|
68
|
+
const { playerId } = session;
|
|
69
|
+
const before = this.records.seen[playerId];
|
|
70
|
+
const player = {
|
|
71
|
+
name: session.playerTitle ?? before?.name,
|
|
72
|
+
product: session.product ?? before?.product,
|
|
73
|
+
platform: session.platform ?? before?.platform,
|
|
74
|
+
address: session.address ?? before?.address,
|
|
75
|
+
local: session.local ?? before?.local,
|
|
76
|
+
lastPlayedAt: now.toISOString(),
|
|
77
|
+
};
|
|
78
|
+
this.records.seen[playerId] = player;
|
|
79
|
+
const newlyShared = session.address !== undefined && player.name !== undefined && this.sighting(player.name, session.address, now);
|
|
80
|
+
let followed = [];
|
|
81
|
+
if (!chosen.has(playerId) && !(options.ignored ?? []).includes(playerId) && !this.records.followed[playerId]
|
|
82
|
+
&& player.name && session.address && this.trusted(session.address)) {
|
|
83
|
+
followed = [...chosen].filter((id) => {
|
|
84
|
+
const other = this.records.seen[id];
|
|
85
|
+
return other !== undefined && other.address === session.address && same(other.name, player.name)
|
|
86
|
+
&& (options.pinned?.has(id) === true || same(other.product, player.product));
|
|
87
|
+
});
|
|
88
|
+
if (followed.length > 0) {
|
|
89
|
+
this.records.followed[playerId] = followed;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
this.save();
|
|
93
|
+
return { followed, newlyShared };
|
|
94
|
+
}
|
|
95
|
+
/** Adds a sighting; true when it shows for the first time that two devices report this name. */
|
|
96
|
+
sighting(name, address, now) {
|
|
97
|
+
if (!this.trusted(address)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const k = key(name);
|
|
101
|
+
const recent = (this.records.sightings[k] ?? []).filter((s) => now.getTime() - Date.parse(s.at) < SIGHTINGS_MS);
|
|
102
|
+
if (recent.at(-1)?.address !== address) {
|
|
103
|
+
recent.push({ address, at: now.toISOString() });
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
recent[recent.length - 1] = { address, at: now.toISOString() };
|
|
107
|
+
}
|
|
108
|
+
this.records.sightings[k] = recent.slice(-SIGHTINGS_KEPT);
|
|
109
|
+
// Back at an address after another one: an address change moves a device once, never back.
|
|
110
|
+
const earlier = recent.slice(0, -2).some((s) => s.address === address);
|
|
111
|
+
const wasShared = this.records.sharedNames[k] !== undefined;
|
|
112
|
+
if (earlier) {
|
|
113
|
+
this.records.sharedNames[k] = now.toISOString();
|
|
114
|
+
}
|
|
115
|
+
return earlier && !wasShared;
|
|
116
|
+
}
|
|
117
|
+
save() {
|
|
118
|
+
const snapshot = structuredClone(this.records);
|
|
119
|
+
this.saving = this.saving
|
|
120
|
+
.then(() => this.storage.writePlayers(snapshot))
|
|
121
|
+
.catch((error) => this.onError(`Could not save players.json: ${describe(error)}`));
|
|
122
|
+
}
|
|
123
|
+
/** Resolves once pending writes finish. */
|
|
124
|
+
flush() {
|
|
125
|
+
return this.saving;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const emptyRecords = () => ({ seen: {}, followed: {}, sightings: {}, sharedNames: {} });
|
|
129
|
+
const object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
130
|
+
export function parseRecords(value) {
|
|
131
|
+
const raw = object(value);
|
|
132
|
+
const records = emptyRecords();
|
|
133
|
+
for (const [id, entry] of Object.entries(object(raw.seen))) {
|
|
134
|
+
const player = object(entry);
|
|
135
|
+
if (typeof player.lastPlayedAt === 'string') {
|
|
136
|
+
records.seen[id] = {
|
|
137
|
+
lastPlayedAt: player.lastPlayedAt,
|
|
138
|
+
...Object.fromEntries(['name', 'product', 'platform', 'address']
|
|
139
|
+
.filter((k) => typeof player[k] === 'string').map((k) => [k, player[k]])),
|
|
140
|
+
...(typeof player.local === 'boolean' ? { local: player.local } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const [id, targets] of Object.entries(object(raw.followed))) {
|
|
145
|
+
if (Array.isArray(targets) && targets.length > 0 && targets.every((t) => typeof t === 'string')) {
|
|
146
|
+
records.followed[id] = targets;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const [name, list] of Object.entries(object(raw.sightings))) {
|
|
150
|
+
if (Array.isArray(list)) {
|
|
151
|
+
records.sightings[name] = list.flatMap((s) => {
|
|
152
|
+
const sighting = object(s);
|
|
153
|
+
return typeof sighting.address === 'string' && typeof sighting.at === 'string' ? [{ address: sighting.address, at: sighting.at }] : [];
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [name, at] of Object.entries(object(raw.sharedNames))) {
|
|
158
|
+
if (typeof at === 'string') {
|
|
159
|
+
records.sharedNames[name] = at;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return records;
|
|
163
|
+
}
|
package/dist/plex/convert.js
CHANGED
|
@@ -34,6 +34,7 @@ function toSession(playerId, player, user, metadata) {
|
|
|
34
34
|
playerTitle: player?.title,
|
|
35
35
|
product: player?.product,
|
|
36
36
|
platform: player?.platform,
|
|
37
|
+
...(player?.address ? { address: player.address } : {}),
|
|
37
38
|
user,
|
|
38
39
|
local: toBoolean(player?.local),
|
|
39
40
|
media: toMedia(metadata),
|
package/dist/plex/discovery.js
CHANGED