homebridge-plex-intermission 1.0.1 → 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 CHANGED
@@ -5,6 +5,41 @@ 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
+
8
43
  ## [1.0.1] - 2026-10-23
9
44
 
10
45
  ### Fixed
@@ -72,6 +107,8 @@ First public release.
72
107
  Home app.
73
108
  - Lost connections to Plex are retried with their reasons logged, with a warning if Plex stays unreachable.
74
109
 
75
- [Unreleased]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.1...HEAD
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
76
113
  [1.0.1]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.0...v1.0.1
77
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.** Players and libraries come from a list; no tokens or IDs to copy.
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,7 +31,7 @@ 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 player, person and library (including Live TV), and by the media itself: wide movies, 4K, HDR,
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
@@ -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 players, people and libraries.
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.
@@ -96,6 +98,9 @@ Intermission talks only to plex.tv and your Plex servers, with no analytics. See
96
98
  Intermission makes of something as you do it, press **Watch live** there instead.
97
99
  - **Webhooks:** **Settings**, then **Plex Pass and webhooks**, shows whether Plex has the webhook, whether your server's
98
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.
99
104
  - **Sensors not in the Home app:** with the child bridge, add Intermission in the Home app with the code on the Sensors
100
105
  tab after restarting Homebridge.
101
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
- sensors: parseSensors(config.sensors, log),
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 parseSensors(value, log) {
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
  },
@@ -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, contextOf(event.session, undefined, this.options.server.id), event.session);
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 = contextOf(webhook.session, webhook.media, this.options.server.id);
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, contextOf(snapshot.session, undefined, this.options.server.id))
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,7 +253,7 @@ 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 = contextOf(session, undefined, this.options.server.id);
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))) {
@@ -251,6 +264,71 @@ export class Controller {
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/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();
@@ -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
+ }
@@ -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),
@@ -68,6 +68,7 @@ export async function discover(servers, plexTv, accountToken) {
68
68
  name: session.playerTitle,
69
69
  product: session.product,
70
70
  platform: session.platform,
71
+ address: session.address,
71
72
  playingNow: true,
72
73
  lastUsedAt: Date.now(),
73
74
  });
package/dist/report.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { commandLine, person, player, readDiagnostics, redact, scrubSecrets, tailDiagnostics } from './diagnostics.js';
3
+ import { isPrivateAddress } from './sensors/rules.js';
3
4
  import { hidePublicAddresses, maskAddress } from './network.js';
4
5
  import { webhookOptionsFrom } from './setup-service.js';
5
6
  const PORT_STATES = {
@@ -98,6 +99,15 @@ export async function diagnosticsReport(service, input, now) {
98
99
  const tell = [found?.product, id.slice(0, 8)].filter(Boolean).join(', ');
99
100
  return found?.name ? `${player(found.name)} (${tell})` : id;
100
101
  };
102
+ const devices = new Map((Array.isArray(config.devices) ? config.devices : []).map((d) => [d.id, d]));
103
+ const describeDevice = (id) => {
104
+ const device = devices.get(id);
105
+ if (!device) {
106
+ return `${id} (no longer defined, matches nothing)`;
107
+ }
108
+ const apps = device.apps?.length ? `, only ${device.apps.map((a) => a.slice(0, 8)).join(', ')}` : '';
109
+ return `${player(device.name ?? device.match)} (device, any app named ${player(device.match)} at home${apps})`;
110
+ };
101
111
  const libraryNames = new Map((selection.libraries ?? []).map((l) => [l.key, l.title ?? l.key]));
102
112
  const commands = new Map((config.commands?.list ?? []).map((c) => [c.id, c]));
103
113
  const list = (values, name, all) => {
@@ -120,7 +130,12 @@ export async function diagnosticsReport(service, input, now) {
120
130
  lines.push(sensor.type === 'button'
121
131
  ? ` pressed on: ${list(sensor.events, (e) => e, 'nothing')}`
122
132
  : ` on while: ${String(sensor.state)}${Array.isArray(sensor.except) && sensor.except.length ? `, except ${sensor.except.join(' and ')}` : ''}`);
123
- lines.push(` players: ${list(f.players, describePlayer, 'all')}`);
133
+ const chosen = [...(Array.isArray(f.devices) ? f.devices.map((d) => describeDevice(String(d))) : []),
134
+ ...(Array.isArray(f.players) ? f.players.map((p) => describePlayer(String(p))) : [])];
135
+ lines.push(` players: ${chosen.length ? chosen.join(', ') : 'all'}`);
136
+ if (Array.isArray(f.exceptPlayers) && f.exceptPlayers.length) {
137
+ lines.push(` except apps: ${f.exceptPlayers.map((p) => describePlayer(String(p))).join(', ')}`);
138
+ }
124
139
  lines.push(` people: ${list(f.users, (u) => person(u), 'all')}`);
125
140
  lines.push(` libraries: ${list(f.libraries, (k) => libraryNames.get(k) ?? k, 'all')}`);
126
141
  if (sensor.media && Object.keys(sensor.media).length) {
@@ -136,6 +151,19 @@ export async function diagnosticsReport(service, input, now) {
136
151
  lines.push(`${command.name ?? '?'}: ${commandLine(command.command ?? '')}`);
137
152
  }
138
153
  }
154
+ const { seen, followed, sharedNames } = await service.players();
155
+ const seenIds = Object.keys(seen).sort((a, b) => Date.parse(seen[b].lastPlayedAt) - Date.parse(seen[a].lastPlayedAt));
156
+ if (seenIds.length > 0) {
157
+ lines.push('', `== Players seen playing (${seenIds.length}) ==`);
158
+ for (const id of seenIds) {
159
+ const p = seen[id];
160
+ const followsAs = followed[id]?.length ? `, followed as ${followed[id].map((t) => t.slice(0, 8)).join(', ')}` : '';
161
+ const where = [p.address ? (isPrivateAddress(p.address) ? 'private address' : 'public address') : '', p.local === undefined ? '' : `Plex says ${p.local ? 'local' : 'remote'}`];
162
+ lines.push(`${p.name ? player(p.name) : '?'} (${[p.product, p.platform, id.slice(0, 8)].filter(Boolean).join(', ')}): last played ${p.lastPlayedAt}`
163
+ + `${where.filter(Boolean).map((w) => `, ${w}`).join('')}${players.has(id) ? ', chosen' : ''}${followsAs}`);
164
+ }
165
+ lines.push(...networkCheck(Object.values(seen), Object.keys(sharedNames)));
166
+ }
139
167
  const log = (await readDiagnostics(service.storage.directory)).reverse();
140
168
  lines.push('', `== Recent log (${log.length} lines, newest first, detailed) ==`, ...(log.length ? log : [NOTHING_LOGGED]));
141
169
  return hidePublicAddresses(scrubSecrets(redact(lines.join('\n'), input.includeNames === true), await secretsOf(service)));
@@ -157,3 +185,26 @@ async function secretsOf(service) {
157
185
  const { webhookSecret } = await service.storage.state();
158
186
  return [webhookSecret, credentials?.accountToken, ...Object.values(credentials?.serverTokens ?? {})].filter((s) => Boolean(s));
159
187
  }
188
+ /** What the players' addresses say about the network, for "my Apple TV is ignored" reports. */
189
+ function networkCheck(seen, sharedNames) {
190
+ const lines = ['', '== Network check =='];
191
+ const byAddress = new Map();
192
+ for (const p of seen) {
193
+ if (p.address) {
194
+ byAddress.set(p.address, [...(byAddress.get(p.address) ?? []), p]);
195
+ }
196
+ }
197
+ const crowded = [...byAddress.values()].filter((group) => new Set(group.flatMap((p) => (p.platform ? [p.platform.toLowerCase()] : []))).size > 1);
198
+ lines.push(crowded.length
199
+ ? 'Players on different platforms share an address, so Plex does not see their real addresses (Docker Desktop, rootless Docker or a proxy?). Addresses are not used to tell devices apart.'
200
+ : `Players' addresses look real (${byAddress.size} distinct).`);
201
+ const privateButRemote = seen.filter((p) => isPrivateAddress(p.address) && p.local === false).length;
202
+ if (privateButRemote > 0) {
203
+ lines.push(`${privateButRemote} player(s) with a private address are marked remote by Plex. Intermission still counts them as at home; `
204
+ + 'adding your home network under LAN Networks in Plex\'s Network settings fixes it in Plex too.');
205
+ }
206
+ if (sharedNames.length) {
207
+ lines.push(`Names reported by two devices: ${sharedNames.map((n) => player(n)).join(', ')}.`);
208
+ }
209
+ return lines;
210
+ }