homebridge-plex-intermission 0.0.0-stage → 1.0.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -2
  4. package/config.schema.json +19 -0
  5. package/dist/child-bridge.js +43 -0
  6. package/dist/commands.js +71 -0
  7. package/dist/config.js +193 -0
  8. package/dist/controller.js +295 -0
  9. package/dist/diagnostics.js +149 -0
  10. package/dist/engine/clock.js +23 -0
  11. package/dist/engine/credits.js +40 -0
  12. package/dist/engine/engine.js +527 -0
  13. package/dist/engine/types.js +10 -0
  14. package/dist/errors.js +4 -0
  15. package/dist/index.js +5 -0
  16. package/dist/network.js +63 -0
  17. package/dist/platform.js +258 -0
  18. package/dist/plex/convert.js +107 -0
  19. package/dist/plex/discovery.js +93 -0
  20. package/dist/plex/library-types.js +45 -0
  21. package/dist/plex/marker-cache.js +33 -0
  22. package/dist/plex/media-facts.js +39 -0
  23. package/dist/plex/multipart.js +33 -0
  24. package/dist/plex/notifications.js +158 -0
  25. package/dist/plex/plex-tv.js +166 -0
  26. package/dist/plex/server.js +166 -0
  27. package/dist/plex/types.js +2 -0
  28. package/dist/plex/webhook-server.js +111 -0
  29. package/dist/report.js +139 -0
  30. package/dist/runtime.js +17 -0
  31. package/dist/sensors/rules.js +88 -0
  32. package/dist/sensors/sensor-accessory.js +88 -0
  33. package/dist/sensors/sensor-hub.js +74 -0
  34. package/dist/settings.js +4 -0
  35. package/dist/setup-service.js +352 -0
  36. package/dist/storage.js +108 -0
  37. package/homebridge-ui/public/app.css +281 -0
  38. package/homebridge-ui/public/app.js +1707 -0
  39. package/homebridge-ui/public/device-icons.js +256 -0
  40. package/homebridge-ui/public/guide/automations.webp +0 -0
  41. package/homebridge-ui/public/guide/convert.webp +0 -0
  42. package/homebridge-ui/public/guide/set-lights.webp +0 -0
  43. package/homebridge-ui/public/guide/shortcut-new.webp +0 -0
  44. package/homebridge-ui/public/guide/steps.webp +0 -0
  45. package/homebridge-ui/public/guide/tiles.webp +0 -0
  46. package/homebridge-ui/public/guide/when.webp +0 -0
  47. package/homebridge-ui/public/guide.js +134 -0
  48. package/homebridge-ui/public/icon.svg +16 -0
  49. package/homebridge-ui/public/index.html +8 -0
  50. package/homebridge-ui/public/model.js +429 -0
  51. package/homebridge-ui/server.js +86 -0
  52. package/icon.png +0 -0
  53. package/package.json +65 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [1.0.0] - 2026-10-23
9
+
10
+ First public release.
11
+
12
+ ### Playback
13
+
14
+ - Follows Plex through the server's push notifications and, with Plex Pass, webhooks, using whichever arrives first.
15
+ No polling.
16
+ - Credits from Plex's credits markers, re-timed on every skip and rewind. Movies and episodes without a marker use 95%
17
+ of the runtime, or no credits if you turn the fallback off.
18
+ - What counts as the credits, one setting for every sensor: mid-credits scenes count as playing (default), as credits,
19
+ or only the final credits count.
20
+ - Intros from Plex's intro markers.
21
+ - Pauses under 1.5 seconds and Apple TV skip bursts change nothing.
22
+ - A stop partway through counts after 1 second. A stop in the credits or near the end waits 10 seconds for autoplay,
23
+ so the next episode, or the movie after a cinema trailer, never passes through Nothing Playing.
24
+ - Sensors keep their state across Homebridge restarts until Plex has been read.
25
+ - Works without Plex Pass, with play noticed at the next progress report. Plex Pass is re-checked daily; when it
26
+ starts or ends, the log and the settings page say so.
27
+
28
+ ### Sensors
29
+
30
+ - Occupancy (recommended), motion and contact sensors that are on while something plays, is paused, is in the
31
+ credits or intro, or while nothing plays, with optional exceptions for the credits and intros.
32
+ - Buttons for one-off events, including the Plex Pass webhook events.
33
+ - Filters by player, person and library, including Live TV.
34
+ - Media conditions: picture shape, resolution, HDR, rating or Plex labels, read from the library item.
35
+ - Presets: Simple, Simple + Credits and Every State (exactly one of four sensors on at a time).
36
+ - Several Plex servers at once.
37
+ - Optional commands, run when a sensor turns on or off or a button is pressed.
38
+ - Runs on its own child bridge by default.
39
+
40
+ ### Settings page
41
+
42
+ - Sign in with Plex, in a browser window or with a plex.tv/link code.
43
+ - Setup in four steps: servers, Plex Pass and webhooks, players, people and libraries, then a preset.
44
+ - Webhook setup that shows the account's existing webhooks, keeps them, and turns on the server's Webhooks setting.
45
+ - A Guide with the Home automations for your presets, fade recipes made with Convert To Shortcut, and screenshots.
46
+ - Diagnostics: one text file with the setup, the sensors and Intermission's recent log, without the Plex sign-in,
47
+ tokens, webhook key or public addresses, and with titles and names replaced unless you include them.
48
+ - Reset all settings, or reset Intermission completely.
49
+
50
+ ### Troubleshooting
51
+
52
+ - The log shows every sensor change with what was playing; Detailed logging adds every Plex report and decision.
53
+ - Intermission keeps its own detailed log whatever the setting, so a problem can be reported without reproducing it.
54
+ - The diagnostics file records the time zone, whether saved settings are running, and whether the bridge is in the
55
+ Home app.
56
+ - Lost connections to Plex are retried with their reasons logged, with a warning if Plex stays unreachable.
57
+
58
+ [Unreleased]: https://github.com/maxrogers/homebridge-plex-intermission/compare/v1.0.0...HEAD
59
+ [1.0.0]: https://github.com/maxrogers/homebridge-plex-intermission/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maxrogers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,118 @@
1
- # Temporary Holding Version
1
+ <p align="center"><img src="icon.png" width="128" alt="Intermission for Plex"></p>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <h1 align="center">Intermission for Plex</h1>
4
+
5
+ <p align="center">Apple Home sensors for every moment of Plex playback, timed to the credits.<br>Lights down when the movie starts, up when the credits roll.</p>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/homebridge-plex-intermission"><img src="https://img.shields.io/npm/v/homebridge-plex-intermission" alt="npm"></a>
9
+ <a href="https://github.com/maxrogers/homebridge-plex-intermission/actions/workflows/ci.yml"><img src="https://github.com/maxrogers/homebridge-plex-intermission/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
10
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT"></a>
11
+ </p>
12
+
13
+ Most Plex plugins give Apple Home a single "playing" sensor. Intermission adds:
14
+
15
+ - **Credits timing.** Lights come up when Plex's credits markers say the credits start, never before, re-timed when you skip.
16
+ - **A sensor for each moment.** Playing, paused, credits and nothing playing, so each gets its own scene.
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.
19
+
20
+ I build things I need for myself, and sometimes I share them. This [Homebridge](https://homebridge.io) plugin is one
21
+ of those: I wanted the room to react to movie night using nothing but Apple Home.
22
+
23
+ <p align="center"><img src="docs/settings.png" width="640" alt="The Intermission settings page with the Every State sensors"></p>
24
+
25
+ ## What else it does
26
+
27
+ Intermission never controls your lights; you choose what happens in the Home app.
28
+
29
+ - **Mid-credits scenes** can dim the lights again, and short pauses or skip bursts never flicker them.
30
+ - **Sensors, not just buttons.** Home can always read a sensor, so a hub restart or a Wi-Fi drop never leaves your
31
+ 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,
33
+ ratings or Plex labels.
34
+ - **A Guide** in the settings page with the Home automations for your preset, including gentle fades, with screenshots.
35
+ - **Diagnostics** in one file for bug reports, never with your Plex sign-in, and without titles or names unless you
36
+ include them.
37
+
38
+ ## Presets
39
+
40
+ | Preset | Sensors | Good for |
41
+ |---|---|---|
42
+ | Simple | Media Playing | Lights down while anything plays, back up for everything else |
43
+ | Simple + Credits (recommended) | Media Playing, off during the credits | Most living rooms and theaters |
44
+ | Every State | Media Playing, Media Paused, Credits Rolling, Nothing Playing | A different scene for each moment; exactly one sensor is on at a time |
45
+
46
+ The plugin's **Guide** tab shows the Home automations to make for your preset, with screenshots, including gentle
47
+ fades made with Convert To Shortcut.
48
+
49
+ ## Install
50
+
51
+ 1. In the Homebridge UI, open **Plugins**, search for **Intermission for Plex** and install it.
52
+ 2. Open the plugin's settings and **Sign in with Plex**.
53
+ 3. Choose your servers, then your players, people and libraries.
54
+ 4. Choose a preset and save. Intermission runs on its own child bridge by default; restart Homebridge, then add it in
55
+ the Home app with the code shown on the Sensors tab.
56
+ 5. Make the automations from the Guide tab.
57
+
58
+ ### Requirements
59
+
60
+ - Homebridge 2 (Homebridge 1 is not supported)
61
+ - Node.js 22.10 or later, 24 or 26
62
+ - Plex Media Server, signed in to a Plex account
63
+ - For webhooks (Plex Pass), Plex must be able to reach Homebridge on the webhook port, 32550 by default. If Homebridge
64
+ runs in Docker without host networking, publish that port.
65
+
66
+ ## Plex Pass
67
+
68
+ Plex Pass on the server owner's account is recommended but not required. Without it:
69
+
70
+ - Play is noticed with Plex's next progress report, up to about 10 seconds after you press play. Pause, resume, skips,
71
+ stop and the credits are still instant.
72
+ - Plex doesn't find credits and intro markers for new media. Items that already have markers keep them; others use 95%
73
+ of the runtime for the credits (you can change or turn off this fallback).
74
+ - Webhook-only events, such as "Marked as watched" and "Added to a library", aren't available for buttons.
75
+
76
+ If you add Plex Pass later, Intermission notices within a day and offers to set up webhooks.
77
+
78
+ ## How it works
79
+
80
+ Plex Media Server pushes playback updates over a websocket the moment they happen, and with Plex Pass it also posts
81
+ webhooks. Intermission uses whichever arrives first. When something starts playing, it reads where the credits begin
82
+ and sets a timer for that moment, moving it whenever you skip. Short pauses and skip bursts are ignored, so lights
83
+ never flicker. When playback stops near the end, sensors wait a few seconds in case the next episode autoplays.
84
+ [ARCHITECTURE.md](ARCHITECTURE.md) has the details and the measurements behind them.
85
+
86
+ ## Privacy and security
87
+
88
+ Your Plex sign-in is stored outside Homebridge's `config.json`, readable only by Homebridge, and never logged.
89
+ Intermission talks only to plex.tv and your Plex servers, with no analytics. See [SECURITY.md](SECURITY.md).
90
+
91
+ ## Troubleshooting
92
+
93
+ - **Something didn't happen when it should have:** in the plugin's settings, open **Settings**, then **Diagnostics**,
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.
96
+ - **Webhooks:** **Settings**, then **Plex Pass and webhooks**, shows whether Plex has the webhook, whether your server's
97
+ Webhooks setting is on, and whether Intermission is listening on its port.
98
+ - **Sensors not in the Home app:** with the child bridge, add Intermission in the Home app with the code on the Sensors
99
+ tab after restarting Homebridge.
100
+
101
+ ## Support
102
+
103
+ - Found a bug? [Report it](https://github.com/maxrogers/homebridge-plex-intermission/issues/new?template=bug_report.yml)
104
+ and attach the diagnostics file. Never post your Plex token.
105
+ - Have an idea? [Suggest it](https://github.com/maxrogers/homebridge-plex-intermission/issues/new?template=feature_request.yml).
106
+ - Pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
107
+ - If it makes movie night better, you can [buy me a coffee](https://ko-fi.com/maxrogers).
108
+
109
+ ## Acknowledgements
110
+
111
+ - [homebridge-plex-credits](https://github.com/DavidD303/homebridge-plex-metadata/tree/credits-aware-playback) by
112
+ DavidD303, whose credits-marker approach this plugin builds on
113
+ - [homebridge-plex-metadata](https://github.com/davidmuzi/homebridge-plex-metadata) by davidmuzi, which
114
+ homebridge-plex-credits is based on
115
+
116
+ ## License
117
+
118
+ [MIT](LICENSE). Not affiliated with or endorsed by Plex, Inc. "Plex" is used only to describe compatibility.
@@ -0,0 +1,19 @@
1
+ {
2
+ "pluginAlias": "PlexIntermission",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "customUi": true,
6
+ "customUiPath": "./homebridge-ui",
7
+ "headerDisplay": "Sign-in and sensors are managed in this plugin's own page.",
8
+ "schema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "name": {
12
+ "title": "Platform name",
13
+ "type": "string",
14
+ "default": "Intermission",
15
+ "description": "Shown in the Homebridge log."
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,43 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /** Homebridge's own child bridge category: Bridge. */
4
+ const BRIDGE_CATEGORY = 2;
5
+ /**
6
+ * The HomeKit setup URI, computed exactly as HAP-NodeJS does (Accessory.setupURI), so the QR code
7
+ * Intermission shows is the one the Home app expects.
8
+ */
9
+ export function setupURI(pincode, category, setupID) {
10
+ const buffer = Buffer.alloc(8);
11
+ let low = parseInt(pincode.replace(/-/g, ''), 10);
12
+ const high = category >> 1;
13
+ low |= 1 << 28; // Supports IP.
14
+ buffer.writeUInt32BE(low >>> 0, 4);
15
+ if (category & 1) {
16
+ buffer[4] = buffer[4] | (1 << 7);
17
+ }
18
+ buffer.writeUInt32BE(high, 0);
19
+ let payload = (buffer.readUInt32BE(4) + buffer.readUInt32BE(0) * 0x100000000).toString(36).toUpperCase();
20
+ while (payload.length < 9) {
21
+ payload = `0${payload}`;
22
+ }
23
+ return `X-HM://${payload}${setupID}`;
24
+ }
25
+ /**
26
+ * Pairing details of Intermission's child bridge, from the file HAP-NodeJS keeps for it in
27
+ * Homebridge's `persist` folder. The Homebridge UI shows the same code on its own pages.
28
+ */
29
+ export async function bridgePairing(homebridgeStoragePath, username) {
30
+ const file = join(homebridgeStoragePath, 'persist', `AccessoryInfo.${username.replace(/:/g, '').toUpperCase()}.json`);
31
+ let saved;
32
+ try {
33
+ saved = JSON.parse(await readFile(file, 'utf8'));
34
+ }
35
+ catch {
36
+ return { published: false, paired: false };
37
+ }
38
+ const paired = Object.keys(saved.pairedClients ?? {}).length > 0;
39
+ if (!saved.pincode || !saved.setupID) {
40
+ return { published: true, paired };
41
+ }
42
+ return { published: true, paired, pincode: saved.pincode, setupURI: setupURI(saved.pincode, saved.category ?? BRIDGE_CATEGORY, saved.setupID) };
43
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { commandLine } from './diagnostics.js';
3
+ const PLACEHOLDER = /\{(sensor|state|event|title|type|library|player|user)\}/g;
4
+ const OUTPUT_LIMIT = 4000;
5
+ /** Quotes a value so the shell passes it through as one literal argument. */
6
+ export function shellQuote(value, platform = process.platform) {
7
+ if (platform === 'win32') {
8
+ return `"${value.replace(/"/g, '""').replace(/%/g, '%%')}"`;
9
+ }
10
+ return `'${value.replace(/'/g, `'\\''`)}'`;
11
+ }
12
+ /** Fills in {placeholders}. Values are quoted, so titles with quotes or `;` can't change the command. */
13
+ export function expandCommand(command, context, platform = process.platform) {
14
+ return command.replace(PLACEHOLDER, (_match, key) => shellQuote(context[key] ?? '', platform));
15
+ }
16
+ /**
17
+ * Runs named commands on the Homebridge machine (inside the container when Homebridge runs in
18
+ * Docker). Each run is independent, limited in time, and logged; failures never affect the sensors.
19
+ */
20
+ export class CommandRunner {
21
+ log;
22
+ timeoutMs;
23
+ constructor(log, timeoutMs = 30_000) {
24
+ this.log = log;
25
+ this.timeoutMs = timeoutMs;
26
+ }
27
+ run(command, context) {
28
+ const line = expandCommand(command.command, context);
29
+ this.log.debug('Running command "%s": %s', command.name, commandLine(line));
30
+ return new Promise((resolve) => {
31
+ let output = '';
32
+ let timedOut = false;
33
+ const child = spawn(line, { shell: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
34
+ const collect = (chunk) => {
35
+ if (output.length < OUTPUT_LIMIT) {
36
+ output += chunk.toString('utf8').slice(0, OUTPUT_LIMIT - output.length);
37
+ }
38
+ };
39
+ child.stdout.on('data', collect);
40
+ child.stderr.on('data', collect);
41
+ const timer = setTimeout(() => {
42
+ timedOut = true;
43
+ child.kill('SIGKILL');
44
+ }, this.timeoutMs);
45
+ let done = false;
46
+ const finish = (code) => {
47
+ if (done) {
48
+ return;
49
+ }
50
+ done = true;
51
+ clearTimeout(timer);
52
+ const result = { code, output: output.trim(), timedOut };
53
+ if (timedOut) {
54
+ this.log.warn('Command "%s" took longer than %ds and was stopped.', command.name, Math.round(this.timeoutMs / 1000));
55
+ }
56
+ else if (code !== 0) {
57
+ this.log.warn('Command "%s" failed (exit %s)%s', command.name, code, result.output ? `: ${result.output.slice(0, 300)}` : '');
58
+ }
59
+ else {
60
+ this.log.info('Ran command "%s"', command.name);
61
+ }
62
+ resolve(result);
63
+ };
64
+ child.on('error', (error) => {
65
+ output += error.message;
66
+ finish(null);
67
+ });
68
+ child.on('close', (code) => finish(code));
69
+ });
70
+ }
71
+ }
package/dist/config.js ADDED
@@ -0,0 +1,193 @@
1
+ import { DEFAULT_ENGINE_SETTINGS } from './engine/types.js';
2
+ /** Webhook events that only arrive through Plex webhooks (Plex Pass). */
3
+ export const WEBHOOK_EVENTS = [
4
+ 'media.scrobble',
5
+ 'media.rate',
6
+ 'library.new',
7
+ 'library.on.deck',
8
+ 'playback.started',
9
+ 'device.new',
10
+ 'admin.database.backup',
11
+ 'admin.database.corrupted',
12
+ ];
13
+ const ENGINE_EVENTS = ['play', 'pause', 'resume', 'stop', 'seek', 'credits.start', 'credits.cancel', 'intro.start', 'intro.end'];
14
+ /**
15
+ * What a state sensor follows. Playing, Paused and Playing or paused can leave out the credits
16
+ * and/or the intro (`except`); In the credits and In the intro are those moments themselves.
17
+ */
18
+ export const STATE_CONDITIONS = ['playing', 'paused', 'active', 'credits', 'intro', 'idle'];
19
+ /** Moments a Playing, Paused or Playing or paused sensor can leave out. */
20
+ export const EXCEPTIONS = ['credits', 'intro'];
21
+ const CAN_EXCEPT = ['playing', 'paused', 'active'];
22
+ export const SENSOR_TYPES = ['occupancy', 'motion', 'contact', 'button'];
23
+ export const DEFAULT_WEBHOOK_PORT = 32550;
24
+ /** Used for movies and episodes that have no credits marker, unless the user turns it off. */
25
+ const DEFAULT_FALLBACK_PERCENT = 95;
26
+ /**
27
+ * A name the Home app accepts: letters, numbers, spaces and common punctuation, starting and ending
28
+ * with a letter or number (HAP-NodeJS warns about anything else, and Home may refuse the accessory).
29
+ */
30
+ export function homeKitName(name, fallback = 'Sensor') {
31
+ const cleaned = name
32
+ .replace(/[^\p{L}\p{N}\p{Zs}\u2019'&!._:;()/,-]/gu, '')
33
+ .replace(/\s+/g, ' ')
34
+ .replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
35
+ if (cleaned.length >= 2) {
36
+ return cleaned;
37
+ }
38
+ return cleaned ? `${cleaned} ${fallback}` : fallback;
39
+ }
40
+ const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
41
+ const asString = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
42
+ const asStrings = (value) => (Array.isArray(value) ? value.flatMap((v) => asString(v) ?? []) : []);
43
+ /** A number of seconds from the config, clamped to [min, max] and returned in milliseconds. */
44
+ function seconds(raw, key, fallbackMs, min, max) {
45
+ const value = raw[key];
46
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
47
+ return fallbackMs;
48
+ }
49
+ return Math.round(Math.min(max, Math.max(min, value)) * 1000);
50
+ }
51
+ /** Like `seconds`, for a setting stored in minutes. */
52
+ const minutes = (raw, key, fallbackMs, min, max) => seconds(raw, key, fallbackMs / 60, min, max) * 60;
53
+ /**
54
+ * Reads the platform block of config.json. Unknown or out-of-range values fall back to defaults
55
+ * or are clamped to the documented ranges; invalid sensors are skipped with a warning.
56
+ */
57
+ export function parseConfig(raw, log) {
58
+ const config = asRecord(raw);
59
+ const timing = asRecord(config.timing);
60
+ const webhook = asRecord(config.webhook);
61
+ const fallback = timing.fallbackPercent;
62
+ const engine = {
63
+ creditsOffsetMs: seconds(timing, 'creditsOffset', DEFAULT_ENGINE_SETTINGS.creditsOffsetMs, 0, 120),
64
+ creditsMode: timing.midCredits === 'first' || timing.midCredits === 'final' ? timing.midCredits : 'scenes',
65
+ fallbackPercent: fallback === null
66
+ ? null
67
+ : (typeof fallback === 'number' && Number.isFinite(fallback) ? Math.min(99, Math.max(50, Math.round(fallback))) : DEFAULT_FALLBACK_PERCENT),
68
+ rearmDistanceMs: seconds(timing, 'rearmDistance', DEFAULT_ENGINE_SETTINGS.rearmDistanceMs, 20, 600),
69
+ pauseDebounceMs: seconds(timing, 'pauseDebounce', DEFAULT_ENGINE_SETTINGS.pauseDebounceMs, 0, 30),
70
+ stopHoldMs: seconds(timing, 'stopHold', DEFAULT_ENGINE_SETTINGS.stopHoldMs, 0, 60),
71
+ minItemLengthMs: seconds(timing, 'minItemLength', DEFAULT_ENGINE_SETTINGS.minItemLengthMs, 0, 3600),
72
+ seekThresholdMs: DEFAULT_ENGINE_SETTINGS.seekThresholdMs,
73
+ };
74
+ // 0 turns it off, otherwise 1 to 30 minutes.
75
+ const safetyResyncMs = timing.safetyResync === 0 ? 0 : minutes(timing, 'safetyResync', 5 * 60_000, 1, 30);
76
+ const port = webhook.port;
77
+ return {
78
+ name: asString(config.name) ?? 'Intermission',
79
+ setupId: asString(config.setupId),
80
+ servers: parseServers(config.servers),
81
+ sensors: parseSensors(config.sensors, log),
82
+ webhook: {
83
+ enabled: webhook.enabled !== false,
84
+ port: typeof port === 'number' && Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_WEBHOOK_PORT,
85
+ advertisedUrl: asString(webhook.advertisedUrl),
86
+ },
87
+ engine,
88
+ safetyResyncMs,
89
+ detailedLogging: config.detailedLogging === true,
90
+ commands: parseCommands(config.commands),
91
+ };
92
+ }
93
+ function parseCommands(value) {
94
+ const section = asRecord(value);
95
+ if (section.enabled !== true || !Array.isArray(section.list)) {
96
+ return [];
97
+ }
98
+ return section.list.flatMap((entry) => {
99
+ const command = asRecord(entry);
100
+ const id = asString(command.id);
101
+ const name = asString(command.name);
102
+ const line = asString(command.command);
103
+ return id && name && line ? [{ id, name, command: line }] : [];
104
+ });
105
+ }
106
+ function parseServers(value) {
107
+ if (!Array.isArray(value)) {
108
+ return [];
109
+ }
110
+ const seen = new Set();
111
+ return value.flatMap((entry) => {
112
+ const server = asRecord(entry);
113
+ const id = asString(server.id);
114
+ const url = asString(server.url);
115
+ if (!id || !url || seen.has(id)) {
116
+ return [];
117
+ }
118
+ seen.add(id);
119
+ return [{ id, name: asString(server.name) ?? 'Plex', url }];
120
+ });
121
+ }
122
+ function parseMedia(value) {
123
+ const raw = asRecord(value);
124
+ const pick = (allowed) => (list) => {
125
+ const values = asStrings(list).filter((v) => allowed.includes(v));
126
+ return values.length > 0 ? [...new Set(values)] : undefined;
127
+ };
128
+ const strings = (list) => {
129
+ const values = [...new Set(asStrings(list))];
130
+ return values.length > 0 ? values : undefined;
131
+ };
132
+ const conditions = {
133
+ shape: raw.shape === 'wide' || raw.shape === 'standard' ? raw.shape : undefined,
134
+ resolution: pick(['4k', 'hd', 'sd'])(raw.resolution),
135
+ dynamicRange: pick(['dv', 'hdr', 'sdr'])(raw.dynamicRange),
136
+ ratings: strings(raw.ratings),
137
+ labels: strings(raw.labels),
138
+ };
139
+ return Object.fromEntries(Object.entries(conditions).filter(([, v]) => v !== undefined));
140
+ }
141
+ export function hasMediaConditions(conditions) {
142
+ return Object.keys(conditions).length > 0;
143
+ }
144
+ function parseSensors(value, log) {
145
+ if (!Array.isArray(value)) {
146
+ return [];
147
+ }
148
+ const known = new Set([...ENGINE_EVENTS, ...WEBHOOK_EVENTS]);
149
+ const seen = new Set();
150
+ return value.flatMap((entry, index) => {
151
+ const sensor = asRecord(entry);
152
+ const id = asString(sensor.id);
153
+ const given = asString(sensor.name);
154
+ const type = SENSOR_TYPES.find((t) => t === sensor.type);
155
+ const name = given && homeKitName(given, type === 'button' ? 'Button' : 'Sensor');
156
+ if (!id || !name || !type || seen.has(id)) {
157
+ log.warn('Skipping sensor %d: it needs a unique id, a name and a type.', index + 1);
158
+ return [];
159
+ }
160
+ seen.add(id);
161
+ const filters = asRecord(sensor.filters);
162
+ const parsed = {
163
+ id,
164
+ name,
165
+ type,
166
+ events: [],
167
+ except: [],
168
+ run: {
169
+ on: asStrings(asRecord(sensor.run).on),
170
+ off: asStrings(asRecord(sensor.run).off),
171
+ press: asStrings(asRecord(sensor.run).press),
172
+ },
173
+ filters: {
174
+ players: asStrings(filters.players),
175
+ users: asStrings(filters.users),
176
+ libraries: asStrings(filters.libraries),
177
+ },
178
+ media: type === 'button' ? {} : parseMedia(sensor.media),
179
+ };
180
+ if (type === 'button') {
181
+ parsed.events = asStrings(sensor.events).filter((event) => known.has(event));
182
+ if (parsed.events.length === 0) {
183
+ log.warn('Sensor "%s" has no events, so it will never trigger.', name);
184
+ }
185
+ }
186
+ else {
187
+ parsed.state = STATE_CONDITIONS.find((c) => c === sensor.state) ?? 'playing';
188
+ const excepted = asStrings(sensor.except).filter((e) => EXCEPTIONS.includes(e));
189
+ parsed.except = CAN_EXCEPT.includes(parsed.state) ? [...new Set(excepted)] : [];
190
+ }
191
+ return [parsed];
192
+ });
193
+ }