From 2f2dfa3e91c9268e85952e30e1b48286990c40c6 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 22 May 2026 01:36:11 -0700 Subject: [PATCH] fix(jellyfin): fix discovery loop, device identity, tray state, and Disc - Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting --- .../fix-discord-presence-jellyfin-title.md | 4 + .../fix-jellyfin-discovery-playback-loop.md | 4 + changes/fix-jellyfin-host-device-identity.md | 4 + ...ix-jellyfin-tray-discovery-active-state.md | 4 + ...x-jellyfin-tray-discovery-stale-session.md | 4 + changes/jellyfin-picker-log-level.md | 4 + config.example.jsonc | 232 ++++++---------- docs-site/configuration.md | 7 +- docs-site/jellyfin-integration.md | 5 +- docs-site/public/config.example.jsonc | 232 ++++++---------- launcher/jellyfin.ts | 24 +- launcher/main.test.ts | 22 ++ plugin/subminer/lifecycle.lua | 11 +- plugin/subminer/process.lua | 44 +-- scripts/test-plugin-start-gate.lua | 102 ++++++- src/config/config.test.ts | 13 +- .../definitions/defaults-integrations.ts | 4 - .../definitions/options-integrations.ts | 26 -- src/config/resolve/integrations.ts | 3 - src/config/settings/registry.test.ts | 4 - src/config/settings/registry.ts | 4 - src/core/services/discord-presence.test.ts | 16 ++ src/core/services/discord-presence.ts | 14 +- src/main.ts | 84 +++--- src/main/main-wiring.test.ts | 27 ++ .../runtime/app-lifecycle-actions.test.ts | 43 +++ src/main/runtime/app-lifecycle-actions.ts | 7 +- src/main/runtime/autoplay-ready-gate.test.ts | 81 +++++- src/main/runtime/autoplay-ready-gate.ts | 16 ++ .../composers/jellyfin-remote-composer.ts | 1 + .../jellyfin-runtime-composer.test.ts | 9 +- .../composers/jellyfin-runtime-composer.ts | 7 +- src/main/runtime/domains/jellyfin.ts | 1 + src/main/runtime/jellyfin-cli-auth.test.ts | 40 ++- src/main/runtime/jellyfin-cli-auth.ts | 9 - .../jellyfin-client-info-main-deps.test.ts | 9 +- .../runtime/jellyfin-client-info-main-deps.ts | 4 +- src/main/runtime/jellyfin-client-info.test.ts | 50 ++-- src/main/runtime/jellyfin-client-info.ts | 24 +- .../runtime/jellyfin-device-identity.test.ts | 24 ++ src/main/runtime/jellyfin-device-identity.ts | 18 ++ .../jellyfin-playback-launch-main-deps.ts | 3 + .../runtime/jellyfin-playback-launch.test.ts | 98 ++++++- src/main/runtime/jellyfin-playback-launch.ts | 27 +- .../runtime/jellyfin-remote-commands.test.ts | 26 ++ src/main/runtime/jellyfin-remote-commands.ts | 4 + src/main/runtime/jellyfin-remote-main-deps.ts | 3 + .../runtime/jellyfin-remote-playback.test.ts | 32 +++ src/main/runtime/jellyfin-remote-playback.ts | 15 +- .../jellyfin-remote-session-lifecycle.test.ts | 139 +++++++++- .../jellyfin-remote-session-lifecycle.ts | 36 ++- .../jellyfin-remote-session-main-deps.test.ts | 28 +- .../jellyfin-remote-session-main-deps.ts | 4 + .../jellyfin-subtitle-cache-io.test.ts | 69 +++++ .../runtime/jellyfin-subtitle-cache-io.ts | 73 +++++ .../runtime/jellyfin-subtitle-preload.test.ts | 262 +++++++++++++++++- src/main/runtime/jellyfin-subtitle-preload.ts | 202 ++++++++++++-- .../runtime/jellyfin-tray-discovery.test.ts | 118 ++++++++ src/main/runtime/jellyfin-tray-discovery.ts | 34 ++- .../runtime/subtitle-prefetch-runtime.test.ts | 30 ++ src/main/runtime/subtitle-prefetch-runtime.ts | 13 + src/main/runtime/tray-lifecycle.test.ts | 57 ++++ src/main/runtime/tray-main-actions.test.ts | 11 +- src/main/runtime/tray-main-actions.ts | 11 +- src/main/runtime/tray-main-deps.test.ts | 9 +- src/main/runtime/tray-main-deps.ts | 7 +- src/main/runtime/tray-runtime.test.ts | 32 ++- src/main/runtime/tray-runtime.ts | 24 +- src/renderer/modals/subsync.test.ts | 26 ++ src/renderer/modals/subsync.ts | 10 +- src/types/config.ts | 4 - src/types/integrations.ts | 4 - 72 files changed, 2063 insertions(+), 589 deletions(-) create mode 100644 changes/fix-discord-presence-jellyfin-title.md create mode 100644 changes/fix-jellyfin-discovery-playback-loop.md create mode 100644 changes/fix-jellyfin-host-device-identity.md create mode 100644 changes/fix-jellyfin-tray-discovery-active-state.md create mode 100644 changes/fix-jellyfin-tray-discovery-stale-session.md create mode 100644 changes/jellyfin-picker-log-level.md create mode 100644 src/main/runtime/jellyfin-device-identity.test.ts create mode 100644 src/main/runtime/jellyfin-device-identity.ts create mode 100644 src/main/runtime/jellyfin-subtitle-cache-io.test.ts create mode 100644 src/main/runtime/jellyfin-subtitle-cache-io.ts diff --git a/changes/fix-discord-presence-jellyfin-title.md b/changes/fix-discord-presence-jellyfin-title.md new file mode 100644 index 00000000..5320f61c --- /dev/null +++ b/changes/fix-discord-presence-jellyfin-title.md @@ -0,0 +1,4 @@ +type: fixed +area: integrations + +- Prevented Discord Rich Presence from falling back to Jellyfin stream URLs, and primed Jellyfin playback titles before loading tokenized streams so presence shows the show/episode title diff --git a/changes/fix-jellyfin-discovery-playback-loop.md b/changes/fix-jellyfin-discovery-playback-loop.md new file mode 100644 index 00000000..19f42947 --- /dev/null +++ b/changes/fix-jellyfin-discovery-playback-loop.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Prevented Jellyfin discovery playback from reloading the active item, misreporting paused mpv playback as still playing, retrying startup unpause after playback is paused again, unpausing after a manual `y-t` overlay toggle during startup, repeatedly restoring the overlay from duplicate ready signals, missing delayed Japanese subtitle selection on startup, letting later German/Russian subtitle loads steal the selected Japanese track, and spawning long-lived sidebar ffmpeg extractors against Jellyfin stream URLs. diff --git a/changes/fix-jellyfin-host-device-identity.md b/changes/fix-jellyfin-host-device-identity.md new file mode 100644 index 00000000..100ad4bc --- /dev/null +++ b/changes/fix-jellyfin-host-device-identity.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Derived Jellyfin cast device identity from the OS hostname, always reports the client as SubMiner, and ignores legacy configurable Jellyfin client/device identity fields so multiple SubMiner installs no longer share the same remote-session identity. diff --git a/changes/fix-jellyfin-tray-discovery-active-state.md b/changes/fix-jellyfin-tray-discovery-active-state.md new file mode 100644 index 00000000..5f829ea3 --- /dev/null +++ b/changes/fix-jellyfin-tray-discovery-active-state.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Keep the Jellyfin discovery tray checkbox in sync on Linux after tray, CLI, or startup remote-session changes, with a visible check mark when Linux tray hosts ignore native checkbox rendering. diff --git a/changes/fix-jellyfin-tray-discovery-stale-session.md b/changes/fix-jellyfin-tray-discovery-stale-session.md new file mode 100644 index 00000000..ec246042 --- /dev/null +++ b/changes/fix-jellyfin-tray-discovery-stale-session.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Restarted stale Jellyfin tray discovery sessions when the server no longer lists the SubMiner cast target, avoiding a needless Jellyfin re-login. diff --git a/changes/jellyfin-picker-log-level.md b/changes/jellyfin-picker-log-level.md new file mode 100644 index 00000000..903aa5da --- /dev/null +++ b/changes/jellyfin-picker-log-level.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Kept Jellyfin picker library discovery working when the running app log level is above info. diff --git a/config.example.jsonc b/config.example.jsonc index 393e2f29..ff230772 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -5,7 +5,6 @@ * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. */ { - // ========================================== // Visible Overlay Auto-Start // Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner. @@ -19,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false + "openBrowser": false, // Open the texthooker page in the default browser when the server starts. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -29,7 +28,7 @@ // ========================================== "websocket": { "enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677 // Built-in subtitle websocket server port. + "port": 6677, // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -39,7 +38,7 @@ // ========================================== "annotationWebsocket": { "enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false - "port": 6678 // Annotated subtitle websocket server port. + "port": 6678, // Annotated subtitle websocket server port. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. // ========================================== @@ -49,7 +48,7 @@ // Hot-reload: logging.level applies live while SubMiner is running. // ========================================== "logging": { - "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. // ========================================== @@ -82,66 +81,66 @@ "leftStickPress": 9, // Raw button index used for controller L3 input. "rightStickPress": 10, // Raw button index used for controller R3 input. "leftTrigger": 6, // Raw button index used for controller L2 input. - "rightTrigger": 7 // Raw button index used for controller R2 input. + "rightTrigger": 7, // Raw button index used for controller R2 input. }, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors. "bindings": { "toggleLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 0 // Raw button index captured for this discrete controller action. + "buttonIndex": 0, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "closeLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 1 // Raw button index captured for this discrete controller action. + "buttonIndex": 1, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleKeyboardOnlyMode": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 3 // Raw button index captured for this discrete controller action. + "buttonIndex": 3, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "mineCard": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 2 // Raw button index captured for this discrete controller action. + "buttonIndex": 2, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "quitMpv": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 6 // Raw button index captured for this discrete controller action. + "buttonIndex": 6, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "previousAudio": { - "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis + "kind": "none", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "nextAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 5 // Raw button index captured for this discrete controller action. + "buttonIndex": 5, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "playCurrentAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 4 // Raw button index captured for this discrete controller action. + "buttonIndex": 4, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleMpvPause": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 9 // Raw button index captured for this discrete controller action. + "buttonIndex": 9, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "leftStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 0, // Raw axis index captured for this analog controller action. - "dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "horizontal", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. "leftStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 1, // Raw axis index captured for this analog controller action. - "dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "vertical", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 3, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 4, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical - } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + }, // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. - "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. + "profiles": {}, // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. // ========================================== @@ -155,7 +154,7 @@ "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false - "jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false + "jellyfinRemoteSession": false, // Warm up Jellyfin remote session at startup. Values: true | false }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // ========================================== @@ -167,7 +166,7 @@ "enabled": true, // Run automatic update checks in the background. Values: true | false "checkIntervalHours": 24, // Minimum hours between automatic update checks. "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none - "channel": "stable" // Release channel used for update checks. Values: stable | prerelease + "channel": "stable", // Release channel used for update checks. Values: stable | prerelease }, // Automatic update check behavior. // ========================================== @@ -193,7 +192,7 @@ "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. - "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. + "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -205,122 +204,76 @@ "keybindings": [ { "key": "Space", // Key setting. - "command": [ - "cycle", - "pause" - ] // Command setting. + "command": ["cycle", "pause"], // Command setting. }, { "key": "KeyF", // Key setting. - "command": [ - "cycle", - "fullscreen" - ] // Command setting. + "command": ["cycle", "fullscreen"], // Command setting. }, { "key": "KeyJ", // Key setting. - "command": [ - "cycle", - "sid" - ] // Command setting. + "command": ["cycle", "sid"], // Command setting. }, { "key": "Shift+KeyJ", // Key setting. - "command": [ - "cycle", - "secondary-sid" - ] // Command setting. + "command": ["cycle", "secondary-sid"], // Command setting. }, { "key": "ArrowRight", // Key setting. - "command": [ - "seek", - 5 - ] // Command setting. + "command": ["seek", 5], // Command setting. }, { "key": "ArrowLeft", // Key setting. - "command": [ - "seek", - -5 - ] // Command setting. + "command": ["seek", -5], // Command setting. }, { "key": "ArrowUp", // Key setting. - "command": [ - "seek", - 60 - ] // Command setting. + "command": ["seek", 60], // Command setting. }, { "key": "ArrowDown", // Key setting. - "command": [ - "seek", - -60 - ] // Command setting. + "command": ["seek", -60], // Command setting. }, { "key": "Shift+KeyH", // Key setting. - "command": [ - "sub-seek", - -1 - ] // Command setting. + "command": ["sub-seek", -1], // Command setting. }, { "key": "Shift+KeyL", // Key setting. - "command": [ - "sub-seek", - 1 - ] // Command setting. + "command": ["sub-seek", 1], // Command setting. }, { "key": "Shift+BracketRight", // Key setting. - "command": [ - "__sub-delay-next-line" - ] // Command setting. + "command": ["__sub-delay-next-line"], // Command setting. }, { "key": "Shift+BracketLeft", // Key setting. - "command": [ - "__sub-delay-prev-line" - ] // Command setting. + "command": ["__sub-delay-prev-line"], // Command setting. }, { "key": "Ctrl+Alt+KeyC", // Key setting. - "command": [ - "__youtube-picker-open" - ] // Command setting. + "command": ["__youtube-picker-open"], // Command setting. }, { "key": "Ctrl+Alt+KeyP", // Key setting. - "command": [ - "__playlist-browser-open" - ] // Command setting. + "command": ["__playlist-browser-open"], // Command setting. }, { "key": "Ctrl+Shift+KeyH", // Key setting. - "command": [ - "__replay-subtitle" - ] // Command setting. + "command": ["__replay-subtitle"], // Command setting. }, { "key": "Ctrl+Shift+KeyL", // Key setting. - "command": [ - "__play-next-subtitle" - ] // Command setting. + "command": ["__play-next-subtitle"], // Command setting. }, { "key": "KeyQ", // Key setting. - "command": [ - "quit" - ] // Command setting. + "command": ["quit"], // Command setting. }, { "key": "Ctrl+KeyW", // Key setting. - "command": [ - "quit" - ] // Command setting. - } + "command": ["quit"], // Command setting. + }, ], // Default and custom keybindings that are merged with built-in defaults. // ========================================== @@ -332,7 +285,7 @@ "secondarySub": { "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false - "defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover + "defaultMode": "hover", // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover }, // Dual subtitle track options. // ========================================== @@ -344,7 +297,7 @@ "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. - "replace": true // Replace the active subtitle file when sync completes. Values: true | false + "replace": true, // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. // ========================================== @@ -352,7 +305,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. + "yPercent": 10, // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -377,7 +330,7 @@ "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. - "--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. + "--subtitle-hover-token-background-color": "transparent", // Subtitle hover token background color setting. }, // CSS declaration object applied to primary subtitles after normal subtitle style defaults. "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false @@ -392,7 +345,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#8bd5ca", // N4 setting. - "N5": "#8aadf4" // N5 setting. + "N5": "#8aadf4", // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -401,13 +354,7 @@ "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. - "bandedColors": [ - "#ed8796", - "#f5a97f", - "#f9e2af", - "#8bd5ca", - "#8aadf4" - ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). }, // Frequency dictionary setting. "secondary": { "css": { @@ -423,9 +370,9 @@ "font-kerning": "normal", // Font kerning setting. "text-rendering": "geometricPrecision", // Text rendering setting. "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. - "backdrop-filter": "blur(6px)" // Backdrop filter setting. - } // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. - } // Secondary setting. + "backdrop-filter": "blur(6px)", // Backdrop filter setting. + }, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. + }, // Secondary setting. }, // Primary and secondary subtitle styling. // ========================================== @@ -450,8 +397,8 @@ "--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting. "--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting. "--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting. - "--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting. - } // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. + "--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)", // Subtitle sidebar hover background color setting. + }, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. }, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. // ========================================== @@ -465,7 +412,7 @@ "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. - "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. + "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // ========================================== @@ -483,23 +430,21 @@ "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy. - "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. + "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. }, // Proxy setting. - "tags": [ - "SubMiner" - ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). - "translation": "SelectionText" // Card field that receives the current selection or translated text. + "translation": "SelectionText", // Card field that receives the current selection or translated text. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "model": "", // Optional model override for Anki AI translation/enrichment flows. - "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. + "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false @@ -513,14 +458,14 @@ "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. - "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. + "maxMediaDuration": 30, // Maximum allowed media clip duration in seconds. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface - "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. + "decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. }, // Known words setting. "behavior": { "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false @@ -528,24 +473,24 @@ "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none - "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false - "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). + "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). + "pattern": "[SubMiner] %f (%t)", // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). }, // Metadata setting. "isLapis": { "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false - "sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards. + "sentenceCardModel": "Lapis", // Note type name used by Lapis sentence cards. }, // Is lapis setting. "isKiku": { "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false - } // Is kiku setting. + "deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -556,7 +501,7 @@ "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10 // Maximum Jimaku search results returned. + "maxEntryResults": 10, // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -565,10 +510,7 @@ // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // ========================================== "youtube": { - "primarySubLanguages": [ - "ja", - "jpn" - ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== @@ -589,9 +531,9 @@ "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false - "voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false - } // Collapsible sections setting. - } // Character dictionary setting. + "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false + }, // Collapsible sections setting. + }, // Character dictionary setting. }, // Anilist API credentials and update behavior. // ========================================== @@ -602,7 +544,7 @@ // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. // ========================================== "yomitan": { - "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + "externalProfilePath": "", // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay }, // Optional external Yomitan profile integration. // ========================================== @@ -622,7 +564,7 @@ "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false - "aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. + "aniskipButtonKey": "TAB", // mpv key used to trigger the AniSkip button while the skip marker is visible. }, // SubMiner-managed mpv launch and bundled plugin options. // ========================================== @@ -636,27 +578,15 @@ "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "username": "", // Default Jellyfin username used during CLI login. - "deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal. - "clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal. - "clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false - "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions. "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": [ - "mkv", - "mp4", - "webm", - "mov", - "flac", - "mp3", - "aac" - ], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== @@ -668,7 +598,7 @@ "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "updateIntervalMs": 3000, // Minimum interval between presence payload updates. - "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. + "debounceMs": 750, // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. // ========================================== @@ -693,13 +623,13 @@ "sessionsDays": 0, // Session retention window in days. Use 0 to keep all. "dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all. "monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all. - "vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable. + "vacuumIntervalDays": 0, // Minimum days between VACUUM runs. Use 0 to disable. }, // Retention setting. "lifetimeSummaries": { "global": true, // Maintain global lifetime stats rows. Values: true | false "anime": true, // Maintain per-anime lifetime stats rows. Values: true | false - "media": true // Maintain per-media lifetime stats rows. Values: true | false - } // Lifetime summaries setting. + "media": true, // Maintain per-media lifetime stats rows. Values: true | false + }, // Lifetime summaries setting. }, // Enable/disable immersion tracking. // ========================================== @@ -712,6 +642,6 @@ "markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry. "serverPort": 6969, // Port for the stats HTTP server. "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false - "autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false - } // Local immersion stats dashboard served on localhost and available as an in-app overlay. + "autoOpenBrowser": false, // Automatically open the stats dashboard in a browser when the server starts. Values: true | false + }, // Local immersion stats dashboard served on localhost and available as an in-app overlay. } diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 1ad0778f..4280c6e2 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1258,7 +1258,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner "remoteControlEnabled": true, "remoteControlAutoConnect": true, "autoAnnounce": false, - "remoteControlDeviceName": "SubMiner", "defaultLibraryId": "", "directPlayPreferred": true, "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], @@ -1273,21 +1272,17 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner | `serverUrl` | string (URL) | Jellyfin server base URL | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `username` | string | Default username used by `--jellyfin-login` | -| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) | -| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) | -| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | -| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | -Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. +Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=` on launcher/app invocations when needed. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 8e1b4175..ad38822e 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -29,7 +29,6 @@ SubMiner includes an optional Jellyfin CLI integration for: "remoteControlEnabled": true, "remoteControlAutoConnect": true, "autoAnnounce": false, - "remoteControlDeviceName": "SubMiner", "defaultLibraryId": "", "pullPictures": false, "iconCacheDir": "/tmp/subminer-jellyfin-icons", @@ -50,7 +49,7 @@ subminer jellyfin -l \ --password 'your-password' ``` -`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. +`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username, and refreshes recent servers. Passwords are never stored. 3. List libraries: @@ -70,7 +69,7 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray): subminer jellyfin -d ``` -After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart. +After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. By default, Jellyfin sees the cast target as the OS hostname (`uname -n` on Linux). If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart. Stop discovery session/app: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 393e2f29..ff230772 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -5,7 +5,6 @@ * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. */ { - // ========================================== // Visible Overlay Auto-Start // Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner. @@ -19,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false + "openBrowser": false, // Open the texthooker page in the default browser when the server starts. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -29,7 +28,7 @@ // ========================================== "websocket": { "enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677 // Built-in subtitle websocket server port. + "port": 6677, // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -39,7 +38,7 @@ // ========================================== "annotationWebsocket": { "enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false - "port": 6678 // Annotated subtitle websocket server port. + "port": 6678, // Annotated subtitle websocket server port. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. // ========================================== @@ -49,7 +48,7 @@ // Hot-reload: logging.level applies live while SubMiner is running. // ========================================== "logging": { - "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. // ========================================== @@ -82,66 +81,66 @@ "leftStickPress": 9, // Raw button index used for controller L3 input. "rightStickPress": 10, // Raw button index used for controller R3 input. "leftTrigger": 6, // Raw button index used for controller L2 input. - "rightTrigger": 7 // Raw button index used for controller R2 input. + "rightTrigger": 7, // Raw button index used for controller R2 input. }, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors. "bindings": { "toggleLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 0 // Raw button index captured for this discrete controller action. + "buttonIndex": 0, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "closeLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 1 // Raw button index captured for this discrete controller action. + "buttonIndex": 1, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleKeyboardOnlyMode": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 3 // Raw button index captured for this discrete controller action. + "buttonIndex": 3, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "mineCard": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 2 // Raw button index captured for this discrete controller action. + "buttonIndex": 2, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "quitMpv": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 6 // Raw button index captured for this discrete controller action. + "buttonIndex": 6, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "previousAudio": { - "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis + "kind": "none", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "nextAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 5 // Raw button index captured for this discrete controller action. + "buttonIndex": 5, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "playCurrentAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 4 // Raw button index captured for this discrete controller action. + "buttonIndex": 4, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleMpvPause": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 9 // Raw button index captured for this discrete controller action. + "buttonIndex": 9, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "leftStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 0, // Raw axis index captured for this analog controller action. - "dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "horizontal", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. "leftStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 1, // Raw axis index captured for this analog controller action. - "dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "vertical", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 3, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 4, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical - } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + }, // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. - "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. + "profiles": {}, // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. // ========================================== @@ -155,7 +154,7 @@ "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false - "jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false + "jellyfinRemoteSession": false, // Warm up Jellyfin remote session at startup. Values: true | false }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // ========================================== @@ -167,7 +166,7 @@ "enabled": true, // Run automatic update checks in the background. Values: true | false "checkIntervalHours": 24, // Minimum hours between automatic update checks. "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none - "channel": "stable" // Release channel used for update checks. Values: stable | prerelease + "channel": "stable", // Release channel used for update checks. Values: stable | prerelease }, // Automatic update check behavior. // ========================================== @@ -193,7 +192,7 @@ "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. - "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. + "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -205,122 +204,76 @@ "keybindings": [ { "key": "Space", // Key setting. - "command": [ - "cycle", - "pause" - ] // Command setting. + "command": ["cycle", "pause"], // Command setting. }, { "key": "KeyF", // Key setting. - "command": [ - "cycle", - "fullscreen" - ] // Command setting. + "command": ["cycle", "fullscreen"], // Command setting. }, { "key": "KeyJ", // Key setting. - "command": [ - "cycle", - "sid" - ] // Command setting. + "command": ["cycle", "sid"], // Command setting. }, { "key": "Shift+KeyJ", // Key setting. - "command": [ - "cycle", - "secondary-sid" - ] // Command setting. + "command": ["cycle", "secondary-sid"], // Command setting. }, { "key": "ArrowRight", // Key setting. - "command": [ - "seek", - 5 - ] // Command setting. + "command": ["seek", 5], // Command setting. }, { "key": "ArrowLeft", // Key setting. - "command": [ - "seek", - -5 - ] // Command setting. + "command": ["seek", -5], // Command setting. }, { "key": "ArrowUp", // Key setting. - "command": [ - "seek", - 60 - ] // Command setting. + "command": ["seek", 60], // Command setting. }, { "key": "ArrowDown", // Key setting. - "command": [ - "seek", - -60 - ] // Command setting. + "command": ["seek", -60], // Command setting. }, { "key": "Shift+KeyH", // Key setting. - "command": [ - "sub-seek", - -1 - ] // Command setting. + "command": ["sub-seek", -1], // Command setting. }, { "key": "Shift+KeyL", // Key setting. - "command": [ - "sub-seek", - 1 - ] // Command setting. + "command": ["sub-seek", 1], // Command setting. }, { "key": "Shift+BracketRight", // Key setting. - "command": [ - "__sub-delay-next-line" - ] // Command setting. + "command": ["__sub-delay-next-line"], // Command setting. }, { "key": "Shift+BracketLeft", // Key setting. - "command": [ - "__sub-delay-prev-line" - ] // Command setting. + "command": ["__sub-delay-prev-line"], // Command setting. }, { "key": "Ctrl+Alt+KeyC", // Key setting. - "command": [ - "__youtube-picker-open" - ] // Command setting. + "command": ["__youtube-picker-open"], // Command setting. }, { "key": "Ctrl+Alt+KeyP", // Key setting. - "command": [ - "__playlist-browser-open" - ] // Command setting. + "command": ["__playlist-browser-open"], // Command setting. }, { "key": "Ctrl+Shift+KeyH", // Key setting. - "command": [ - "__replay-subtitle" - ] // Command setting. + "command": ["__replay-subtitle"], // Command setting. }, { "key": "Ctrl+Shift+KeyL", // Key setting. - "command": [ - "__play-next-subtitle" - ] // Command setting. + "command": ["__play-next-subtitle"], // Command setting. }, { "key": "KeyQ", // Key setting. - "command": [ - "quit" - ] // Command setting. + "command": ["quit"], // Command setting. }, { "key": "Ctrl+KeyW", // Key setting. - "command": [ - "quit" - ] // Command setting. - } + "command": ["quit"], // Command setting. + }, ], // Default and custom keybindings that are merged with built-in defaults. // ========================================== @@ -332,7 +285,7 @@ "secondarySub": { "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false - "defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover + "defaultMode": "hover", // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover }, // Dual subtitle track options. // ========================================== @@ -344,7 +297,7 @@ "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. - "replace": true // Replace the active subtitle file when sync completes. Values: true | false + "replace": true, // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. // ========================================== @@ -352,7 +305,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. + "yPercent": 10, // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -377,7 +330,7 @@ "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. - "--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. + "--subtitle-hover-token-background-color": "transparent", // Subtitle hover token background color setting. }, // CSS declaration object applied to primary subtitles after normal subtitle style defaults. "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false @@ -392,7 +345,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#8bd5ca", // N4 setting. - "N5": "#8aadf4" // N5 setting. + "N5": "#8aadf4", // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -401,13 +354,7 @@ "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. - "bandedColors": [ - "#ed8796", - "#f5a97f", - "#f9e2af", - "#8bd5ca", - "#8aadf4" - ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). }, // Frequency dictionary setting. "secondary": { "css": { @@ -423,9 +370,9 @@ "font-kerning": "normal", // Font kerning setting. "text-rendering": "geometricPrecision", // Text rendering setting. "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. - "backdrop-filter": "blur(6px)" // Backdrop filter setting. - } // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. - } // Secondary setting. + "backdrop-filter": "blur(6px)", // Backdrop filter setting. + }, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. + }, // Secondary setting. }, // Primary and secondary subtitle styling. // ========================================== @@ -450,8 +397,8 @@ "--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting. "--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting. "--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting. - "--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting. - } // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. + "--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)", // Subtitle sidebar hover background color setting. + }, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. }, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. // ========================================== @@ -465,7 +412,7 @@ "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. - "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. + "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // ========================================== @@ -483,23 +430,21 @@ "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy. - "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. + "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. }, // Proxy setting. - "tags": [ - "SubMiner" - ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). - "translation": "SelectionText" // Card field that receives the current selection or translated text. + "translation": "SelectionText", // Card field that receives the current selection or translated text. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "model": "", // Optional model override for Anki AI translation/enrichment flows. - "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. + "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false @@ -513,14 +458,14 @@ "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. - "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. + "maxMediaDuration": 30, // Maximum allowed media clip duration in seconds. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface - "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. + "decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. }, // Known words setting. "behavior": { "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false @@ -528,24 +473,24 @@ "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none - "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false - "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). + "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). + "pattern": "[SubMiner] %f (%t)", // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). }, // Metadata setting. "isLapis": { "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false - "sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards. + "sentenceCardModel": "Lapis", // Note type name used by Lapis sentence cards. }, // Is lapis setting. "isKiku": { "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false - } // Is kiku setting. + "deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -556,7 +501,7 @@ "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10 // Maximum Jimaku search results returned. + "maxEntryResults": 10, // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -565,10 +510,7 @@ // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // ========================================== "youtube": { - "primarySubLanguages": [ - "ja", - "jpn" - ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== @@ -589,9 +531,9 @@ "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false - "voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false - } // Collapsible sections setting. - } // Character dictionary setting. + "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false + }, // Collapsible sections setting. + }, // Character dictionary setting. }, // Anilist API credentials and update behavior. // ========================================== @@ -602,7 +544,7 @@ // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. // ========================================== "yomitan": { - "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + "externalProfilePath": "", // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay }, // Optional external Yomitan profile integration. // ========================================== @@ -622,7 +564,7 @@ "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false - "aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. + "aniskipButtonKey": "TAB", // mpv key used to trigger the AniSkip button while the skip marker is visible. }, // SubMiner-managed mpv launch and bundled plugin options. // ========================================== @@ -636,27 +578,15 @@ "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "username": "", // Default Jellyfin username used during CLI login. - "deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal. - "clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal. - "clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false - "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions. "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": [ - "mkv", - "mp4", - "webm", - "mov", - "flac", - "mp3", - "aac" - ], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== @@ -668,7 +598,7 @@ "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "updateIntervalMs": 3000, // Minimum interval between presence payload updates. - "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. + "debounceMs": 750, // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. // ========================================== @@ -693,13 +623,13 @@ "sessionsDays": 0, // Session retention window in days. Use 0 to keep all. "dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all. "monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all. - "vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable. + "vacuumIntervalDays": 0, // Minimum days between VACUUM runs. Use 0 to disable. }, // Retention setting. "lifetimeSummaries": { "global": true, // Maintain global lifetime stats rows. Values: true | false "anime": true, // Maintain per-anime lifetime stats rows. Values: true | false - "media": true // Maintain per-media lifetime stats rows. Values: true | false - } // Lifetime summaries setting. + "media": true, // Maintain per-media lifetime stats rows. Values: true | false + }, // Lifetime summaries setting. }, // Enable/disable immersion tracking. // ========================================== @@ -712,6 +642,6 @@ "markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry. "serverPort": 6969, // Port for the stats HTTP server. "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false - "autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false - } // Local immersion stats dashboard served on localhost and available as an in-app overlay. + "autoOpenBrowser": false, // Automatically open the stats dashboard in a browser when the server starts. Values: true | false + }, // Local immersion stats dashboard served on localhost and available as an in-app overlay. } diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index 8b27beac..b7bf5fa5 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection( fail('Selected Jellyfin item is not playable.'); } +export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] { + const forwarded = [...appArgs]; + const serverOverride = sanitizeServerUrl(args.jellyfinServer || ''); + if (serverOverride) { + forwarded.push('--jellyfin-server', serverOverride); + } + if (args.passwordStore) { + forwarded.push('--password-store', args.passwordStore); + } + if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) { + forwarded.push('--log-level', args.logLevel); + } + return forwarded; +} + async function runAppJellyfinListCommand( appPath: string, args: Args, @@ -384,14 +399,7 @@ async function runAppJellyfinCommand( appArgs: string[], label: string, ): Promise<{ status: number; output: string; error: string; logOffset: number }> { - const forwardedBase = [...appArgs]; - const serverOverride = sanitizeServerUrl(args.jellyfinServer || ''); - if (serverOverride) { - forwardedBase.push('--jellyfin-server', serverOverride); - } - if (args.passwordStore) { - forwardedBase.push('--password-store', args.passwordStore); - } + const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs); const readLogAppendedSince = (offset: number): string => { const logPath = getMpvLogPath(); diff --git a/launcher/main.test.ts b/launcher/main.test.ts index b4ff4f53..6350aea4 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -17,6 +17,7 @@ import { parseEpisodePathFromDisplay, buildRootSearchGroups, classifyJellyfinChildSelection, + buildForwardedJellyfinAppArgs, } from './jellyfin.js'; type RunResult = { @@ -878,6 +879,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => { ]); }); +test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => { + const forwarded = buildForwardedJellyfinAppArgs( + { + jellyfinServer: 'https://jf.example.test/', + passwordStore: 'gnome-libsecret', + logLevel: 'info', + } as never, + ['--jellyfin-libraries'], + ); + + assert.deepEqual(forwarded, [ + '--jellyfin-libraries', + '--jellyfin-server', + 'https://jf.example.test', + '--password-store', + 'gnome-libsecret', + '--log-level', + 'info', + ]); +}); + test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { const parsed = parseJellyfinErrorFromAppOutput(` [subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 9f077618..31689cd2 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -144,12 +144,21 @@ function M.create(ctx) and previous_media_identity ~= nil and media_identity == previous_media_identity ) + local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded state.pending_reload_media_identity = nil state.current_media_identity = media_identity + if new_media_loaded then + state.suppress_ready_overlay_restore = false + end if same_media_reload then subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") - if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then + if + state.overlay_running + and not state.suppress_ready_overlay_restore + and resolve_auto_start_enabled() + and process.has_matching_mpv_ipc_socket(opts.socket_path) + then process.run_control_command_async("show-visible-overlay", { socket_path = opts.socket_path, }) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index b0fcf3cc..6afff0ce 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -31,6 +31,16 @@ function M.create(ctx) return options_helper.coerce_bool(raw_visible_overlay, false) end + local function resolve_auto_start_visibility_action() + if resolve_visible_overlay_startup() then + if state.suppress_ready_overlay_restore then + return nil + end + return "show-visible-overlay" + end + return "hide-visible-overlay" + end + local function resolve_pause_until_ready() local raw_pause_until_ready = opts.auto_start_pause_until_ready if raw_pause_until_ready == nil then @@ -129,7 +139,7 @@ function M.create(ctx) local function release_auto_play_ready_gate(reason) if not state.auto_play_ready_gate_armed then - return + return false end local should_resume_playback = state.auto_play_ready_should_resume_playback == true disarm_auto_play_ready_gate({ resume_playback = false }) @@ -140,6 +150,7 @@ function M.create(ctx) else subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready")) end + return true end local function arm_auto_play_ready_gate() @@ -179,9 +190,12 @@ function M.create(ctx) end local function notify_auto_play_ready() - release_auto_play_ready_gate("tokenization-ready") + local released_ready_gate = release_auto_play_ready_gate("tokenization-ready") local force_ready_overlay_restore = state.force_ready_overlay_restore == true state.force_ready_overlay_restore = false + if not released_ready_gate and not force_ready_overlay_restore then + return + end if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then return end @@ -224,7 +238,7 @@ function M.create(ctx) local should_show_visible = overrides.show_visible_overlay if should_show_visible == nil then - should_show_visible = resolve_visible_overlay_startup() + should_show_visible = resolve_visible_overlay_startup() and not state.suppress_ready_overlay_restore end if should_show_visible then table.insert(args, "--show-visible-overlay") @@ -399,9 +413,6 @@ function M.create(ctx) local function start_overlay(overrides) overrides = overrides or {} - if overrides.auto_start_trigger == true then - state.suppress_ready_overlay_restore = false - end if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") @@ -424,13 +435,13 @@ function M.create(ctx) elseif not state.auto_play_ready_gate_armed then disarm_auto_play_ready_gate() end - local visibility_action = resolve_visible_overlay_startup() - and "show-visible-overlay" - or "hide-visible-overlay" - run_control_command_async(visibility_action, { - socket_path = socket_path, - log_level = overrides.log_level, - }) + local visibility_action = resolve_auto_start_visibility_action() + if visibility_action ~= nil then + run_control_command_async(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) + end return end subminer_log("info", "process", "Overlay already running") @@ -495,13 +506,13 @@ function M.create(ctx) end if overrides.auto_start_trigger == true then - local visibility_action = resolve_visible_overlay_startup() - and "show-visible-overlay" - or "hide-visible-overlay" + local visibility_action = resolve_auto_start_visibility_action() + if visibility_action ~= nil then run_control_command_async(visibility_action, { socket_path = socket_path, log_level = overrides.log_level, }) + end end end) @@ -576,6 +587,7 @@ function M.create(ctx) return end state.suppress_ready_overlay_restore = true + disarm_auto_play_ready_gate({ resume_playback = false }) run_control_command_async("toggle-visible-overlay", nil, function(ok) if not ok then diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 1476cdd4..d0b70b51 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -1396,7 +1396,7 @@ do "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" ) assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4, + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 3, "duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load" ) assert_true( @@ -1471,6 +1471,33 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for duplicate autoplay-ready scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered") + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1531,6 +1558,10 @@ do count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, "manual toggle-off before readiness should suppress ready-time visible overlay restore" ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off before readiness should not resume playback when readiness arrives" + ) end do @@ -1564,6 +1595,75 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = "/media/jellyfin-stream.m3u8", + media_title = "Jellyfin Episode", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for manual hide duplicate auto-start scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") + recorded.script_messages["subminer-toggle"]() + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "manual toggle-off should suppress duplicate auto-start visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off followed by duplicate auto-start should keep paused playback paused" + ) +end + +do + local media_path = "/media/jellyfin-redirect.m3u8" + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = media_path, + media_title = "Jellyfin Redirect", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for manual hide same-media reload scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-toggle"]() + fire_event(recorded, "end-file", { reason = "redirect" }) + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "manual toggle-off should suppress same-media reload visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off followed by same-media reload should keep paused playback paused" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 9e975e30..bf719f8b 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -74,7 +74,10 @@ test('loads defaults when config is missing', () => { assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, false); - assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); + assert.equal('clientName' in config.jellyfin, false); + assert.equal('remoteControlDeviceName' in config.jellyfin, false); + assert.equal('deviceId' in config.jellyfin, false); + assert.equal('clientVersion' in config.jellyfin, false); assert.equal(config.ai.enabled, false); assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.texthooker.openBrowser, false); @@ -825,7 +828,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns ); }); -test('parses jellyfin remote control fields', () => { +test('parses jellyfin remote control fields and ignores legacy identity fields', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), @@ -836,6 +839,7 @@ test('parses jellyfin remote control fields', () => { "remoteControlEnabled": true, "remoteControlAutoConnect": true, "autoAnnounce": true, + "clientName": "Custom Client", "remoteControlDeviceName": "SubMiner" } }`, @@ -850,7 +854,8 @@ test('parses jellyfin remote control fields', () => { assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, true); - assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); + assert.equal('clientName' in config.jellyfin, false); + assert.equal('remoteControlDeviceName' in config.jellyfin, false); }); test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => { @@ -2462,6 +2467,8 @@ test('template generator includes known keys', () => { assert.match(output, /"startupWarmups":/); assert.match(output, /"updates":/); assert.match(output, /"youtube":/); + assert.doesNotMatch(output, /"deviceId":/); + assert.doesNotMatch(output, /"clientVersion":/); assert.doesNotMatch(output, /"youtubeSubgen":/); assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"preserveLineBreaks": false/); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 81e1e134..ec244d85 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -126,14 +126,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< serverUrl: '', recentServers: [], username: '', - deviceId: 'subminer', - clientName: 'SubMiner', - clientVersion: '0.1.0', defaultLibraryId: '', remoteControlEnabled: true, remoteControlAutoConnect: true, autoAnnounce: false, - remoteControlDeviceName: 'SubMiner', pullPictures: false, iconCacheDir: '/tmp/subminer-jellyfin-icons', directPlayPreferred: true, diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index e875eb88..700fa14c 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -520,26 +520,6 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.jellyfin.username, description: 'Default Jellyfin username used during CLI login.', }, - { - path: 'jellyfin.deviceId', - kind: 'string', - defaultValue: defaultConfig.jellyfin.deviceId, - description: - 'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.', - }, - { - path: 'jellyfin.clientName', - kind: 'string', - defaultValue: defaultConfig.jellyfin.clientName, - description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.', - }, - { - path: 'jellyfin.clientVersion', - kind: 'string', - defaultValue: defaultConfig.jellyfin.clientVersion, - description: - 'Client version sent on the Jellyfin authentication handshake; primarily internal.', - }, { path: 'jellyfin.defaultLibraryId', kind: 'string', @@ -565,12 +545,6 @@ export function buildIntegrationConfigOptionRegistry( description: 'When enabled, automatically trigger remote announce/visibility check on websocket connect.', }, - { - path: 'jellyfin.remoteControlDeviceName', - kind: 'string', - defaultValue: defaultConfig.jellyfin.remoteControlDeviceName, - description: 'Device name reported for Jellyfin remote control sessions.', - }, { path: 'jellyfin.pullPictures', kind: 'boolean', diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 41d22899..f19913e8 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -364,9 +364,6 @@ export function applyIntegrationConfig(context: ResolveContext): void { const stringKeys = [ 'serverUrl', 'username', - 'deviceId', - 'clientName', - 'clientVersion', 'defaultLibraryId', 'iconCacheDir', 'transcodeVideoCodec', diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 28510171..9d574082 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -57,7 +57,6 @@ test('settings registry hides removed modal-only fields', () => { 'shortcuts.multiCopyTimeoutMs', 'anilist.characterDictionary.profileScope', 'jellyfin.directPlayContainers', - 'jellyfin.remoteControlDeviceName', ]) { assert.equal( fields.some((candidate) => candidate.configPath === path), @@ -244,10 +243,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => { 'controller.preferredGamepadLabel', 'controller.profiles', 'youtubeSubgen.whisperBin', - 'jellyfin.clientVersion', 'jellyfin.defaultLibraryId', - 'jellyfin.deviceId', - 'jellyfin.clientName', 'subtitleSidebar.toggleKey', 'jellyfin.recentServers', ]) { diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 5ae21dcd..03521f33 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'anilist.characterDictionary.profileScope', 'jellyfin.accessToken', 'jellyfin.userId', - 'jellyfin.clientName', - 'jellyfin.clientVersion', 'jellyfin.defaultLibraryId', - 'jellyfin.deviceId', 'jellyfin.directPlayContainers', - 'jellyfin.remoteControlDeviceName', 'controller.buttonIndices', 'shortcuts.multiCopyTimeoutMs', 'subtitleSidebar.toggleKey', diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts index 68745fea..4932555f 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () => } }); +test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => { + const payload = buildDiscordPresenceActivity(baseConfig, { + ...baseSnapshot, + mediaTitle: null, + mediaPath: + 'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1', + }); + + assert.equal(payload.details, 'Unknown media'); + assert.equal(payload.state, 'Playing 01:35 / 24:10'); + const serialized = JSON.stringify(payload); + assert.equal(serialized.includes('api_key'), false); + assert.equal(serialized.includes('secret-token'), false); + assert.equal(serialized.includes('/Videos/item-1/stream'), false); +}); + test('service deduplicates identical updates and sends changed timeline', async () => { const sent: DiscordActivityPayload[] = []; const timers = new Map void>(); diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts index 9b211a4f..991eb2c4 100644 --- a/src/core/services/discord-presence.ts +++ b/src/core/services/discord-presence.ts @@ -106,6 +106,15 @@ function basename(filePath: string | null): string { return parts[parts.length - 1] ?? ''; } +function fallbackTitleFromMediaPath(mediaPath: string | null): string { + const trimmed = mediaPath?.trim(); + if (!trimmed) return ''; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) { + return ''; + } + return basename(trimmed).split(/[?#]/)[0] ?? ''; +} + function buildStatus(snapshot: DiscordPresenceSnapshot): string { if (!snapshot.connected || !snapshot.mediaPath) return 'Idle'; if (snapshot.paused) return 'Paused'; @@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity( ): DiscordActivityPayload { const style = resolvePresenceStyle(config.presenceStyle); const status = buildStatus(snapshot); - const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); + const title = sanitizeText( + snapshot.mediaTitle, + fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media', + ); const details = snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; diff --git a/src/main.ts b/src/main.ts index 904d5951..33bec202 100644 --- a/src/main.ts +++ b/src/main.ts @@ -403,6 +403,11 @@ import { launchWindowsMpv, } from './main/runtime/windows-mpv-launch'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; +import { + DEFAULT_JELLYFIN_CLIENT_NAME, + DEFAULT_JELLYFIN_CLIENT_VERSION, + createHostDerivedJellyfinDeviceId, +} from './main/runtime/jellyfin-device-identity'; import { clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime, isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, @@ -507,6 +512,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; +import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createElectronAppUpdater, @@ -613,6 +619,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({ appDataDir: process.env.APPDATA, }); const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; +const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({ + tmpDir: () => os.tmpdir(), + makeTempDir: (prefix) => fs.promises.mkdtemp(prefix), + writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes), + removeDir: (dir, options) => { + fs.rmSync(dir, options); + }, + fetch: (url) => fetch(url), +}); const ANILIST_SETUP_RESPONSE_TYPE = 'token'; const ANILIST_DEFAULT_CLIENT_ID = '36084'; const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; @@ -2825,7 +2840,9 @@ const { }, getJellyfinClientInfoMainDeps: { getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), - getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, + getHostName: () => os.hostname(), + defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME, + defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION, }, waitForMpvConnectedMainDeps: { getMpvClient: () => appState.mpvClient, @@ -2881,41 +2898,8 @@ const { sendMpvCommandRuntime(appState.mpvClient, command); }, wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - cacheSubtitleTrack: async (track) => { - if (!track.deliveryUrl) { - throw new Error('Jellyfin subtitle track has no delivery URL'); - } - - const cacheDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'subminer-jellyfin-subtitles-'), - ); - const urlPath = (() => { - try { - return new URL(track.deliveryUrl).pathname; - } catch { - return track.deliveryUrl; - } - })(); - const ext = path.extname(urlPath).slice(0, 16) || '.srt'; - const subtitlePath = path.join(cacheDir, `track-${track.index}${ext}`); - try { - const response = await fetch(track.deliveryUrl); - if (!response.ok) { - throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`); - } - const bytes = new Uint8Array(await response.arrayBuffer()); - await fs.promises.writeFile(subtitlePath, bytes); - } catch (error) { - fs.rmSync(cacheDir, { recursive: true, force: true }); - throw error; - } - return { path: subtitlePath, cleanupDir: cacheDir }; - }, - cleanupCachedSubtitles: (dirs) => { - for (const dir of dirs) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }, + cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track), + cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs), logDebug: (message, error) => { logger.debug(message, error); }, @@ -2958,6 +2942,9 @@ const { showMpvOsd: (text) => { showMpvOsd(text); }, + updateCurrentMediaTitle: (title) => { + mediaRuntime.updateCurrentMediaTitle(title); + }, recordJellyfinPlaybackMetadata: (metadata) => { ensureImmersionTrackerStarted(); appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata); @@ -3022,11 +3009,13 @@ const { appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; }, createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), - defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, - defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, - defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, + getHostName: () => os.hostname(), + defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()), + defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME, + defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION, logInfo: (message) => logger.info(message), logWarn: (message, details) => logger.warn(message, details), + onSessionStateChanged: () => refreshTrayMenuIfPresent(), }, stopJellyfinRemoteSessionMainDeps: { getCurrentSession: () => appState.jellyfinRemoteSession, @@ -3036,6 +3025,7 @@ const { clearActivePlayback: () => { activeJellyfinRemotePlayback = null; }, + onSessionStateChanged: () => refreshTrayMenuIfPresent(), }, runJellyfinCommandMainDeps: { defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, @@ -3056,7 +3046,6 @@ const { clearStoredSession: () => clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()), patchJellyfinConfig: (session) => { - const clientInfo = getJellyfinClientInfo(); const recentServers = mergeJellyfinRecentServers( session.serverUrl, getResolvedConfig().jellyfin.recentServers || [], @@ -3066,9 +3055,6 @@ const { enabled: true, serverUrl: session.serverUrl, username: session.username, - deviceId: clientInfo.deviceId, - clientName: clientInfo.clientName, - clientVersion: clientInfo.clientVersion, recentServers, }, }); @@ -4389,8 +4375,8 @@ const { broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); + autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); } - autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); currentMediaTokenizationGate.updateCurrentMediaPath(path); managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); startupOsdSequencer.reset(); @@ -6081,6 +6067,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = }, buildTrayMenuTemplateDeps: { buildTrayMenuTemplateRuntime, + platform: process.platform, initializeOverlayRuntime: () => initializeOverlayRuntime(), isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, openSessionHelpModal: () => openSessionHelpOverlay(), @@ -6096,8 +6083,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = isJellyfinConfigured: () => isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), - toggleJellyfinDiscovery: () => - toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()), + toggleJellyfinDiscovery: (checked: boolean) => + toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), { + desiredActive: checked, + }), openAnilistSetupWindow: () => openAnilistSetupWindow(), checkForUpdates: () => { void getUpdateService().checkForUpdates({ source: 'manual' }); @@ -6329,6 +6318,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void { function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); if (!visible) { + autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { @@ -6340,6 +6330,7 @@ function setVisibleOverlayVisible(visible: boolean): void { function toggleVisibleOverlay(): void { ensureOverlayWindowsReadyForVisibilityActions(); + autoplayReadyGate.markCurrentMediaAutoplayReady(); if (overlayManager.getVisibleOverlayVisible()) { cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } else { @@ -6350,6 +6341,7 @@ function toggleVisibleOverlay(): void { } function setOverlayVisible(visible: boolean): void { if (!visible) { + autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 2a7bc4b1..7e561ade 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -43,6 +43,33 @@ test('media path changes clear rendered subtitle state', () => { ); }); +test('same media path updates do not reset autoplay ready fallback state', () => { + const source = readMainSource(); + const actionBlock = source.match( + /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)\n restoreMpvSubVisibility:/, + )?.groups?.body; + + assert.ok(actionBlock); + assert.match( + actionBlock, + /annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/, + ); +}); + +test('manual visible overlay toggles suppress current-media autoplay release', () => { + const source = readMainSource(); + const actionBlock = source.match( + /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(actionBlock); + assert.match(actionBlock, /autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);/); + assert.ok( + actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') < + actionBlock.indexOf('toggleVisibleOverlayHandler();'), + ); +}); + test('main process uses one shared mpv plugin runtime config helper', () => { const source = readMainSource(); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 7207eb67..7fc31d91 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -54,6 +54,49 @@ test('on will quit cleanup handler runs all cleanup steps', () => { assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); }); +test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => { + const calls: string[] = []; + const cleanup = createOnWillQuitCleanupHandler({ + destroyTray: () => {}, + stopConfigHotReload: () => {}, + restorePreviousSecondarySubVisibility: () => {}, + restoreMpvSubVisibility: () => {}, + unregisterAllGlobalShortcuts: () => {}, + stopSubtitleWebsocket: () => {}, + stopTexthookerService: () => {}, + clearWindowsVisibleOverlayForegroundPollLoop: () => {}, + clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, + destroyMainOverlayWindow: () => {}, + destroyModalOverlayWindow: () => {}, + destroyYomitanParserWindow: () => {}, + clearYomitanParserState: () => {}, + stopWindowTracker: () => {}, + flushMpvLog: () => {}, + destroyMpvSocket: () => {}, + clearReconnectTimer: () => {}, + destroySubtitleTimingTracker: () => {}, + destroyImmersionTracker: () => {}, + destroyAnkiIntegration: () => {}, + destroyAnilistSetupWindow: () => {}, + clearAnilistSetupWindow: () => {}, + destroyJellyfinSetupWindow: () => {}, + clearJellyfinSetupWindow: () => {}, + destroyFirstRunSetupWindow: () => {}, + clearFirstRunSetupWindow: () => {}, + destroyYomitanSettingsWindow: () => {}, + clearYomitanSettingsWindow: () => {}, + stopJellyfinRemoteSession: () => { + calls.push('stop-jellyfin-remote'); + throw new Error('stop failed'); + }, + cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), + stopDiscordPresenceService: () => calls.push('stop-discord-presence'), + }); + + assert.throws(() => cleanup(), /stop failed/); + assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']); +}); + test('should restore windows on activate requires initialized runtime and no windows', () => { let initialized = false; let windowCount = 1; diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index cae8232d..82fe1ea5 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -60,8 +60,11 @@ export function createOnWillQuitCleanupHandler(deps: { deps.clearFirstRunSetupWindow(); deps.destroyYomitanSettingsWindow(); deps.clearYomitanSettingsWindow(); - deps.stopJellyfinRemoteSession(); - deps.cleanupJellyfinSubtitleCache(); + try { + deps.stopJellyfinRemoteSession(); + } finally { + deps.cleanupJellyfinSubtitleCache(); + } deps.stopDiscordPresenceService(); }; } diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index 202f1441..34aa1a1f 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media' (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, ), ); - assert.equal(scheduled.length > 0, true); }); test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { @@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t ); }); +test('autoplay ready gate cancels release retries after playback is paused again', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + let playbackPaused = true; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => playbackPaused, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => playbackPaused, + send: ({ command }: { command: Array }) => { + commands.push(command); + if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) { + playbackPaused = false; + } + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + playbackPaused = true; + const retry = scheduled.shift(); + retry?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + commands.filter( + (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ).length, + 1, + ); +}); + +test('autoplay ready gate suppresses release after manual current-media dismissal', async () => { + const commands: Array> = []; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => true, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => true, + send: ({ command }: { command: Array }) => { + commands.push(command); + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback) => { + queueMicrotask(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.markCurrentMediaAutoplayReady(); + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands, []); +}); + test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => { const commands: Array> = []; let targetReady = false; diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index b5044d8d..d704df73 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { const getSignalMediaPath = (): string => deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__'; + const markCurrentMediaAutoplayReady = (): void => { + pendingAutoplayReadySignal = null; + autoPlayReadySignalMediaPath = getSignalMediaPath(); + autoPlayReadySignalGeneration += 1; + }; + const maybeSignalPluginAutoplayReady = ( payload: SubtitleData, options?: { forceWhilePaused?: boolean }, @@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { forceWhilePaused: options?.forceWhilePaused === true, retryDelayMs: releaseRetryDelayMs, }); + let releaseUnpauseSent = false; const isPlaybackPaused = async (client: MpvClientLike): Promise => { try { @@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { return; } + if (releaseUnpauseSent && deps.getPlaybackPaused() === true) { + deps.logDebug( + `[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`, + ); + return; + } + const shouldUnpause = await isPlaybackPaused(mpvClient); if (!shouldUnpause) { return; } mpvClient.send({ command: ['set_property', 'pause', false] }); + releaseUnpauseSent = true; if (attempt < maxReleaseAttempts) { deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); } @@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { flushPendingAutoplayReadySignal, getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath, invalidatePendingAutoplayReadyFallbacks, + markCurrentMediaAutoplayReady, maybeSignalPluginAutoplayReady, }; } diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index a759b88f..75b68376 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -101,6 +101,7 @@ export function composeJellyfinRemoteHandlers( getConfiguredSession: options.getConfiguredSession, getClientInfo: options.getClientInfo, getJellyfinConfig: options.getJellyfinConfig, + getActivePlayback: options.getActivePlayback, playJellyfinItem: options.playJellyfinItem, logWarn: options.logWarn, }); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index adc11403..3304e755 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' }, getJellyfinClientInfoMainDeps: { getResolvedJellyfinConfig: () => ({}) as never, - getDefaultJellyfinConfig: () => ({ - clientName: 'SubMiner', - clientVersion: 'test', - deviceId: 'dev', - }), + getHostName: () => 'workstation', + defaultClientName: 'SubMiner', + defaultClientVersion: 'test', }, waitForMpvConnectedMainDeps: { getMpvClient: () => null, @@ -140,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' defaultDeviceId: 'dev', defaultClientName: 'SubMiner', defaultClientVersion: 'test', + getHostName: () => 'workstation', logInfo: () => {}, logWarn: () => {}, }, diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts index 4e2b5433..30892744 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{ >; startJellyfinRemoteSessionMainDeps: Omit< StartRemoteSessionMainDeps, - 'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand' + | 'getJellyfinConfig' + | 'getClientInfo' + | 'handlePlay' + | 'handlePlaystate' + | 'handleGeneralCommand' >; stopJellyfinRemoteSessionMainDeps: Parameters< typeof createBuildStopJellyfinRemoteSessionMainDepsHandler @@ -236,6 +240,7 @@ export function composeJellyfinRuntimeHandlers( createBuildStartJellyfinRemoteSessionMainDepsHandler({ ...options.startJellyfinRemoteSessionMainDeps, getJellyfinConfig: () => getResolvedJellyfinConfig(), + getClientInfo: () => getJellyfinClientInfo(), handlePlay: (payload) => handleJellyfinRemotePlay(payload), handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), diff --git a/src/main/runtime/domains/jellyfin.ts b/src/main/runtime/domains/jellyfin.ts index db75837e..9c5f9d33 100644 --- a/src/main/runtime/domains/jellyfin.ts +++ b/src/main/runtime/domains/jellyfin.ts @@ -7,6 +7,7 @@ export * from '../jellyfin-client-info'; export * from '../jellyfin-client-info-main-deps'; export * from '../jellyfin-command-dispatch'; export * from '../jellyfin-command-dispatch-main-deps'; +export * from '../jellyfin-device-identity'; export * from '../jellyfin-playback-launch'; export * from '../jellyfin-playback-launch-main-deps'; export * from '../jellyfin-remote-commands'; diff --git a/src/main/runtime/jellyfin-cli-auth.test.ts b/src/main/runtime/jellyfin-cli-auth.test.ts index 94faee83..b659fecf 100644 --- a/src/main/runtime/jellyfin-cli-auth.test.ts +++ b/src/main/runtime/jellyfin-cli-auth.test.ts @@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => { enabled: true, serverUrl: 'http://localhost', username: 'user', - deviceId: 'd1', - clientName: 'SubMiner', - clientVersion: '1.0', recentServers: ['http://localhost'], }, }); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); }); -test('persistJellyfinAuthSession stores client metadata and recent servers', () => { +test('persistJellyfinAuthSession stores session config and recent servers', () => { let patchPayload: unknown = null; let storedSession: unknown = null; @@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', () enabled: true, serverUrl: 'http://localhost:8096', username: 'alice', - deviceId: 'device-1', - clientName: 'SubMiner', - clientVersion: '1.0', recentServers: [ 'http://localhost:8096', 'http://old.example:8096', @@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', () }); }); +test('persistJellyfinAuthSession does not write generated local device id to config', () => { + let patchPayload: unknown = null; + + persistJellyfinAuthSession({ + session: { + serverUrl: 'http://localhost:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }, + clientInfo: { + deviceId: 'subminer-local-pc', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + existingRecentServers: [], + saveStoredSession: () => {}, + patchRawConfig: (patch) => { + patchPayload = patch; + }, + }); + + assert.deepEqual(patchPayload, { + jellyfin: { + enabled: true, + serverUrl: 'http://localhost:8096', + username: 'alice', + recentServers: ['http://localhost:8096'], + }, + }); +}); + test('jellyfin auth handler no-ops when no auth command', async () => { const handleAuth = createHandleJellyfinAuthCommands({ patchRawConfig: () => {}, diff --git a/src/main/runtime/jellyfin-cli-auth.ts b/src/main/runtime/jellyfin-cli-auth.ts index 9368b6fc..57a87eb6 100644 --- a/src/main/runtime/jellyfin-cli-auth.ts +++ b/src/main/runtime/jellyfin-cli-auth.ts @@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: { enabled: boolean; serverUrl: string; username: string; - deviceId: string; - clientName: string; - clientVersion: string; recentServers: string[]; }>; }) => void; @@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: { enabled: true, serverUrl: deps.session.serverUrl, username: deps.session.username, - deviceId: deps.clientInfo.deviceId, - clientName: deps.clientInfo.clientName, - clientVersion: deps.clientInfo.clientVersion, recentServers: mergeJellyfinRecentServers( deps.session.serverUrl, deps.existingRecentServers || [], @@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: { enabled: boolean; serverUrl: string; username: string; - deviceId: string; - clientName: string; - clientVersion: string; }>; }) => void; authenticateWithPassword: ( diff --git a/src/main/runtime/jellyfin-client-info-main-deps.test.ts b/src/main/runtime/jellyfin-client-info-main-deps.test.ts index 8fa98869..e32c177e 100644 --- a/src/main/runtime/jellyfin-client-info-main-deps.test.ts +++ b/src/main/runtime/jellyfin-client-info-main-deps.test.ts @@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => { test('get jellyfin client info main deps builder maps callbacks', () => { const configured = { clientName: 'Configured' }; - const defaults = { clientName: 'Default' }; const deps = createBuildGetJellyfinClientInfoMainDepsHandler({ getResolvedJellyfinConfig: () => configured as never, - getDefaultJellyfinConfig: () => defaults as never, + getHostName: () => 'workstation', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', })(); assert.equal(deps.getResolvedJellyfinConfig(), configured); - assert.equal(deps.getDefaultJellyfinConfig(), defaults); + assert.equal(deps.getHostName?.(), 'workstation'); + assert.equal(deps.defaultClientName, 'SubMiner'); + assert.equal(deps.defaultClientVersion, '1.0.0'); }); diff --git a/src/main/runtime/jellyfin-client-info-main-deps.ts b/src/main/runtime/jellyfin-client-info-main-deps.ts index 343ee216..c6fbee0f 100644 --- a/src/main/runtime/jellyfin-client-info-main-deps.ts +++ b/src/main/runtime/jellyfin-client-info-main-deps.ts @@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler( ) { return (): GetJellyfinClientInfoMainDeps => ({ getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), - getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(), + getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined, + defaultClientName: deps.defaultClientName, + defaultClientVersion: deps.defaultClientVersion, }); } diff --git a/src/main/runtime/jellyfin-client-info.test.ts b/src/main/runtime/jellyfin-client-info.test.ts index 08ffc5ac..1183e42b 100644 --- a/src/main/runtime/jellyfin-client-info.test.ts +++ b/src/main/runtime/jellyfin-client-info.test.ts @@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou test('jellyfin client info resolves defaults when fields are missing', () => { const getClientInfo = createGetJellyfinClientInfoHandler({ - getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never, - getDefaultJellyfinConfig: () => - ({ - clientName: 'SubMiner', - clientVersion: '1.0.0', - deviceId: 'default-device', - }) as never, + getResolvedJellyfinConfig: () => ({ clientName: '' }) as never, + getHostName: () => 'workstation', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', }); assert.deepEqual(getClientInfo(), { clientName: 'SubMiner', clientVersion: '1.0.0', - deviceId: 'default-device', + deviceId: 'workstation', }); }); -test('jellyfin client info keeps explicit config values', () => { +test('jellyfin client info ignores legacy configured client name, device id, and version', () => { const getClientInfo = createGetJellyfinClientInfoHandler({ getResolvedJellyfinConfig: () => ({ @@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => { clientVersion: '2.3.4', deviceId: 'custom-device', }) as never, - getDefaultJellyfinConfig: () => - ({ - clientName: 'SubMiner', - clientVersion: '1.0.0', - deviceId: 'default-device', - }) as never, + getHostName: () => 'Kyle-PC', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', }); assert.deepEqual(getClientInfo(), { - clientName: 'Custom', - clientVersion: '2.3.4', - deviceId: 'custom-device', + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'Kyle-PC', + }); +}); + +test('jellyfin client info ignores legacy configured device id and client version', () => { + const getClientInfo = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => + ({ + clientName: 'SubMiner', + clientVersion: '9.9.9', + deviceId: 'custom-device', + }) as never, + getHostName: () => 'media-box', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', + }); + + assert.deepEqual(getClientInfo(), { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'media-box', }); }); diff --git a/src/main/runtime/jellyfin-client-info.ts b/src/main/runtime/jellyfin-client-info.ts index 24ce3673..b611e3cb 100644 --- a/src/main/runtime/jellyfin-client-info.ts +++ b/src/main/runtime/jellyfin-client-info.ts @@ -1,5 +1,10 @@ import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store'; import type { ResolvedConfig } from '../../types'; +import { + DEFAULT_JELLYFIN_CLIENT_NAME, + DEFAULT_JELLYFIN_CLIENT_VERSION, + createHostDerivedJellyfinDeviceId, +} from './jellyfin-device-identity'; type ResolvedJellyfinConfig = ResolvedConfig['jellyfin']; type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & { @@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: { } export function createGetJellyfinClientInfoHandler(deps: { - getResolvedJellyfinConfig: () => Partial< - Pick - >; - getDefaultJellyfinConfig: () => Partial< - Pick - >; + getResolvedJellyfinConfig: () => unknown; + getHostName?: () => string; + defaultClientName?: string; + defaultClientVersion?: string; }) { return ( - config = deps.getResolvedJellyfinConfig(), + _config = deps.getResolvedJellyfinConfig(), ): { clientName: string; clientVersion: string; deviceId: string; } => { - const defaults = deps.getDefaultJellyfinConfig(); return { - clientName: config.clientName || defaults.clientName || '', - clientVersion: config.clientVersion || defaults.clientVersion || '', - deviceId: config.deviceId || defaults.deviceId || '', + clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME, + clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION, + deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''), }; }; } diff --git a/src/main/runtime/jellyfin-device-identity.test.ts b/src/main/runtime/jellyfin-device-identity.test.ts new file mode 100644 index 00000000..5c223c00 --- /dev/null +++ b/src/main/runtime/jellyfin-device-identity.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createHostDerivedJellyfinDeviceId, + resolveJellyfinRemoteDeviceName, +} from './jellyfin-device-identity'; + +test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => { + assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local'); + assert.equal(createHostDerivedJellyfinDeviceId(''), 'device'); +}); + +test('resolveJellyfinRemoteDeviceName uses hostname by default', () => { + assert.equal( + resolveJellyfinRemoteDeviceName({ + hostName: 'kyle-pc', + }), + 'kyle-pc', + ); +}); + +test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => { + assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device'); +}); diff --git a/src/main/runtime/jellyfin-device-identity.ts b/src/main/runtime/jellyfin-device-identity.ts new file mode 100644 index 00000000..d87f1eb1 --- /dev/null +++ b/src/main/runtime/jellyfin-device-identity.ts @@ -0,0 +1,18 @@ +export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0'; +export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner'; + +export function normalizeJellyfinHostName(value: string): string { + return value.trim(); +} + +export function createHostDerivedJellyfinDeviceId(hostName: string): string { + return normalizeJellyfinHostName(hostName) || 'device'; +} + +export function resolveJellyfinDeviceId(params: { hostName: string }): string { + return createHostDerivedJellyfinDeviceId(params.hostName); +} + +export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string { + return normalizeJellyfinHostName(params.hostName) || 'device'; +} diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.ts index 70fd4508..17597acc 100644 --- a/src/main/runtime/jellyfin-playback-launch-main-deps.ts +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.ts @@ -23,5 +23,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler( recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata ? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata) : undefined, + updateCurrentMediaTitle: deps.updateCurrentMediaTitle + ? (title) => deps.updateCurrentMediaTitle!(title) + : undefined, }); } diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index bfe4de53..a8c905e4 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -100,10 +100,11 @@ test('playback handler drives mpv commands and playback state', async () => { ['set_property', 'sid', 'no'], ['seek', 1.2, 'absolute+exact'], ]); - assert.equal(scheduled.length, 1); - assert.equal(scheduled[0]?.delay, 500); - scheduled[0]?.callback(); - assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); + assert.equal(scheduled.length, 0); + assert.equal( + commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length, + 1, + ); assert.ok(calls.includes('defaults')); assert.ok(calls.includes('visible-overlay')); @@ -133,6 +134,52 @@ test('playback handler drives mpv commands and playback state', async () => { ]); }); +test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => { + const timeline: string[] = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1', + mode: 'direct', + title: 'Galaxy Quest S02E07 A New Hope', + itemTitle: 'A New Hope', + seriesTitle: 'Galaxy Quest', + seasonNumber: 2, + episodeNumber: 7, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}:${String(command[1] ?? '')}`), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + updateCurrentMediaTitle: (title) => timeline.push(`title:${title}`), + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'ep-1', + }); + + const titleIndex = timeline.indexOf('title:Galaxy Quest S02E07 A New Hope'); + const loadIndex = timeline.findIndex((entry) => entry.startsWith('cmd:loadfile:')); + assert.ok(titleIndex >= 0); + assert.ok(loadIndex >= 0); + assert.ok(titleIndex < loadIndex); + assert.equal(timeline[titleIndex]?.includes('api_key'), false); +}); + test('playback handler applies start override to stream url for remote resume', async () => { const commands: Array> = []; const handler = createPlayJellyfinItemInMpvHandler({ @@ -177,3 +224,46 @@ test('playback handler applies start override to stream url for remote resume', assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000'); assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']); }); + +test('playback handler does not let stats metadata failures block playback startup', async () => { + const commands: Array> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 3', + itemTitle: 'Episode 3', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + recordJellyfinPlaybackMetadata: () => { + throw new Error('stats db unavailable'); + }, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-3', + }); + + assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); +}); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index 82322e99..636a4319 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -75,6 +75,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { }) => void; showMpvOsd: (text: string) => void; recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void; + updateCurrentMediaTitle?: (title: string) => void; }) { return async (params: { session: JellyfinAuthSession; @@ -106,24 +107,26 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.applyJellyfinMpvDefaults(mpvClient); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); - deps.recordJellyfinPlaybackMetadata?.({ - mediaPath: playbackUrl, - displayTitle: plan.title, - itemTitle: plan.itemTitle, - seriesTitle: plan.seriesTitle, - seasonNumber: plan.seasonNumber, - episodeNumber: plan.episodeNumber, - itemId: params.itemId, - }); + deps.updateCurrentMediaTitle?.(plan.title); + try { + deps.recordJellyfinPlaybackMetadata?.({ + mediaPath: playbackUrl, + displayTitle: plan.title, + itemTitle: plan.itemTitle, + seriesTitle: plan.seriesTitle, + seasonNumber: plan.seasonNumber, + episodeNumber: plan.episodeNumber, + itemId: params.itemId, + }); + } catch { + // Best-effort stats metadata must not block playback startup. + } deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); if (params.setQuitOnDisconnectArm !== false) { deps.armQuitOnDisconnect(); } deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]); deps.sendMpvCommand(['set_property', 'sid', 'no']); - deps.schedule(() => { - deps.sendMpvCommand(['set_property', 'sid', 'no']); - }, 500); const startTimeTicks = typeof params.startTimeTicksOverride === 'number' diff --git a/src/main/runtime/jellyfin-remote-commands.test.ts b/src/main/runtime/jellyfin-remote-commands.test.ts index 1cffeef9..6bf69a43 100644 --- a/src/main/runtime/jellyfin-remote-commands.test.ts +++ b/src/main/runtime/jellyfin-remote-commands.test.ts @@ -101,6 +101,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']); }); +test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => { + let playCalls = 0; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({}), + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + playJellyfinItem: async () => { + playCalls += 1; + }, + logWarn: () => {}, + }); + + await handlePlay({ ItemIds: ['item-1'] }); + + assert.equal(playCalls, 0); +}); + test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => { const mpvClient = {}; const commands: Array<(string | number)[]> = []; diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts index 17373911..650e2184 100644 --- a/src/main/runtime/jellyfin-remote-commands.ts +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -51,6 +51,7 @@ export type JellyfinRemotePlayHandlerDeps = { getConfiguredSession: () => JellyfinSession | null; getClientInfo: () => JellyfinClientInfo; getJellyfinConfig: () => unknown; + getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null; playJellyfinItem: (params: { session: JellyfinSession; clientInfo: JellyfinClientInfo; @@ -79,6 +80,9 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.'); return; } + if (deps.getActivePlayback?.()?.itemId === itemId) { + return; + } await deps.playJellyfinItem({ session, clientInfo, diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts index aebfa2a6..8de2bd80 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler( getConfiguredSession: () => deps.getConfiguredSession(), getClientInfo: () => deps.getClientInfo(), getJellyfinConfig: () => deps.getJellyfinConfig(), + ...(deps.getActivePlayback + ? { getActivePlayback: () => deps.getActivePlayback?.() ?? null } + : {}), playJellyfinItem: (params) => deps.playJellyfinItem(params), logWarn: (message: string) => deps.logWarn(message), }); diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index 743018c8..742f92d5 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -61,6 +61,38 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn assert.equal(lastProgressAtMs, 5000); }); +test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => { + const reportPayloads: Array<{ isPaused: boolean }> = []; + + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => true, + reportProgress: async (payload) => { + reportPayloads.push({ isPaused: payload.isPaused }); + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3), + }), + getNow: () => 5000, + getLastProgressAtMs: () => 0, + setLastProgressAtMs: () => {}, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + + assert.deepEqual(reportPayloads, [{ isPaused: true }]); +}); + test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => { let called = false; const reportProgress = createReportJellyfinRemoteProgressHandler({ diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index f085ef52..6d54af8c 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -31,6 +31,19 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): return Math.max(0, Math.floor(seconds * ticksPerSecond)); } +function isMpvPauseEnabled(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === 'no' || normalized === 'false' || normalized === '0') { + return false; + } + return true; + } + return false; +} + export type JellyfinRemoteProgressReporterDeps = { getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; clearActivePlayback: () => void; @@ -64,7 +77,7 @@ export function createReportJellyfinRemoteProgressHandler( itemId: playback.itemId, mediaSourceId: playback.mediaSourceId, positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond), - isPaused: paused === true, + isPaused: isMpvPauseEnabled(paused), playMethod: playback.playMethod, audioStreamIndex: playback.audioStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex, diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts index 80722056..76519e58 100644 --- a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts @@ -13,10 +13,6 @@ function createConfig(overrides?: Partial>) { serverUrl: 'http://localhost', accessToken: 'token', userId: 'user-id', - deviceId: '', - clientName: '', - clientVersion: '', - remoteControlDeviceName: '', autoAnnounce: false, ...(overrides || {}), } as never; @@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => { } | null = null; let started = false; const infos: string[] = []; + let stateChanges = 0; const startRemote = createStartJellyfinRemoteSessionHandler({ getJellyfinConfig: () => createConfig({ clientName: 'Desk' }), getCurrentSession: () => null, @@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => { storedSession = session as never; }, createRemoteSessionService: (options) => { - assert.equal(options.deviceName, 'Desk'); + assert.equal(options.deviceName, 'workstation'); return { start: () => { started = true; @@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, logInfo: (message) => infos.push(message), logWarn: () => {}, + onSessionStateChanged: () => { + stateChanges += 1; + }, }); await startRemote(); assert.equal(started, true); assert.ok(storedSession); - assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).'))); + assert.equal(stateChanges, 1); + assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).'))); +}); + +test('start handler uses hostname-derived client info and visible device name', async () => { + let createdOptions: { + deviceId: string; + clientName: string; + clientVersion: string; + deviceName: string; + } | null = null; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => + createConfig({ + clientName: 'SubMiner', + }), + getClientInfo: () => ({ + deviceId: 'kyle-pc', + clientName: 'SubMiner', + clientVersion: '0.1.0', + }), + getHostName: () => 'kyle-pc', + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: (options) => { + createdOptions = { + deviceId: options.deviceId, + clientName: options.clientName, + clientVersion: options.clientVersion, + deviceName: options.deviceName, + }; + return { + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'subminer', + defaultClientName: 'SubMiner', + defaultClientVersion: '0.1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote({ explicit: true }); + + assert.deepEqual(createdOptions, { + deviceId: 'kyle-pc', + clientName: 'SubMiner', + clientVersion: '0.1.0', + deviceName: 'kyle-pc', + }); +}); + +test('start handler ignores configured visible device name', async () => { + let createdDeviceName = ''; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => + createConfig({ + remoteControlDeviceName: 'SubMiner Cachy sudacode', + }), + getClientInfo: () => ({ + deviceId: 'cachy', + clientName: 'SubMiner', + clientVersion: '0.1.0', + }), + getHostName: () => 'cachy', + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: (options) => { + createdDeviceName = options.deviceName; + return { + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'subminer', + defaultClientName: 'SubMiner', + defaultClientVersion: '0.1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote({ explicit: true }); + + assert.equal(createdDeviceName, 'cachy'); }); test('start handler stops previous session before replacing', async () => { @@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => { test('stop handler stops active session and clears playback', () => { let stopCalls = 0; let clearCalls = 0; + let stateChanges = 0; let currentSession: { stop: () => void } | null = { stop: () => { stopCalls += 1; @@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => { clearActivePlayback: () => { clearCalls += 1; }, + onSessionStateChanged: () => { + stateChanges += 1; + }, }); stopRemote(); assert.equal(stopCalls, 1); assert.equal(clearCalls, 1); assert.equal(currentSession, null); + assert.equal(stateChanges, 1); }); diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts index df95c9a0..233f94af 100644 --- a/src/main/runtime/jellyfin-remote-session-lifecycle.ts +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts @@ -1,3 +1,5 @@ +import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity'; + type JellyfinRemoteConfig = { enabled: boolean; remoteControlEnabled: boolean; @@ -5,11 +7,13 @@ type JellyfinRemoteConfig = { serverUrl: string; accessToken?: string; userId?: string; + autoAnnounce: boolean; +}; + +type JellyfinClientInfo = { deviceId: string; clientName: string; clientVersion: string; - remoteControlDeviceName: string; - autoAnnounce: boolean; }; type JellyfinRemoteService = { @@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: { getCurrentSession: () => JellyfinRemoteService | null; setCurrentSession: (session: JellyfinRemoteService | null) => void; createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService; + getClientInfo: () => JellyfinClientInfo; + getHostName: () => string; defaultDeviceId: string; defaultClientName: string; defaultClientVersion: string; @@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: { handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise; logInfo: (message: string) => void; logWarn: (message: string, details?: unknown) => void; + onSessionStateChanged?: () => void; }) { return async (options?: { explicit?: boolean }): Promise => { const jellyfinConfig = deps.getJellyfinConfig(); @@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: { if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return; if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; + const clientInfo = deps.getClientInfo(); + const clientName = clientInfo.clientName || deps.defaultClientName; + const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion; + const deviceName = resolveJellyfinRemoteDeviceName({ + hostName: deps.getHostName(), + }); + const existing = deps.getCurrentSession(); if (existing) { existing.stop(); @@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: { const service = deps.createRemoteSessionService({ serverUrl: jellyfinConfig.serverUrl, accessToken: jellyfinConfig.accessToken, - deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId, - clientName: jellyfinConfig.clientName || deps.defaultClientName, - clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion, - deviceName: - jellyfinConfig.remoteControlDeviceName || - jellyfinConfig.clientName || - deps.defaultClientName, + deviceId: clientInfo.deviceId || deps.defaultDeviceId, + clientName, + clientVersion, + deviceName, capabilities: { PlayableMediaTypes: 'Video,Audio', SupportedCommands: @@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: { service.start(); deps.setCurrentSession(service); - deps.logInfo( - `Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, - ); + deps.onSessionStateChanged?.(); + deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`); }; } @@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: { getCurrentSession: () => JellyfinRemoteService | null; setCurrentSession: (session: JellyfinRemoteService | null) => void; clearActivePlayback: () => void; + onSessionStateChanged?: () => void; }) { return (): void => { const session = deps.getCurrentSession(); @@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: { session.stop(); deps.setCurrentSession(null); deps.clearActivePlayback(); + deps.onSessionStateChanged?.(); }; } diff --git a/src/main/runtime/jellyfin-remote-session-main-deps.test.ts b/src/main/runtime/jellyfin-remote-session-main-deps.test.ts index 6d5f9b6b..ce8ffc69 100644 --- a/src/main/runtime/jellyfin-remote-session-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-session-main-deps.test.ts @@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async () getCurrentSession: () => null, setCurrentSession: () => calls.push('set-session'), createRemoteSessionService: () => session as never, + getClientInfo: () => + ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }) as never, + getHostName: () => 'workstation', defaultDeviceId: 'device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', @@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async () }, logInfo: (message) => calls.push(`info:${message}`), logWarn: (message) => calls.push(`warn:${message}`), + onSessionStateChanged: () => calls.push('state-changed'), })(); assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' }); assert.equal(deps.defaultDeviceId, 'device'); assert.equal(deps.defaultClientName, 'SubMiner'); assert.equal(deps.defaultClientVersion, '1.0'); + assert.equal(deps.getHostName(), 'workstation'); + assert.deepEqual(deps.getClientInfo(), { + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }); assert.equal(deps.createRemoteSessionService({} as never), session); await deps.handlePlay({}); await deps.handlePlaystate({}); await deps.handleGeneralCommand({}); deps.logInfo('connected'); deps.logWarn('missing'); - assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']); + deps.onSessionStateChanged?.(); + assert.deepEqual(calls, [ + 'play', + 'playstate', + 'general', + 'info:connected', + 'warn:missing', + 'state-changed', + ]); }); test('stop jellyfin remote session main deps builder maps callbacks', () => { @@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => { getCurrentSession: () => session as never, setCurrentSession: () => calls.push('set-null'), clearActivePlayback: () => calls.push('clear'), + onSessionStateChanged: () => calls.push('state-changed'), })(); assert.equal(deps.getCurrentSession(), session); deps.setCurrentSession(null); deps.clearActivePlayback(); - assert.deepEqual(calls, ['set-null', 'clear']); + deps.onSessionStateChanged?.(); + assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']); }); diff --git a/src/main/runtime/jellyfin-remote-session-main-deps.ts b/src/main/runtime/jellyfin-remote-session-main-deps.ts index 49b2e152..47accb1b 100644 --- a/src/main/runtime/jellyfin-remote-session-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-session-main-deps.ts @@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler( getCurrentSession: () => deps.getCurrentSession(), setCurrentSession: (session) => deps.setCurrentSession(session), createRemoteSessionService: (options) => deps.createRemoteSessionService(options), + getClientInfo: () => deps.getClientInfo(), + getHostName: () => deps.getHostName(), defaultDeviceId: deps.defaultDeviceId, defaultClientName: deps.defaultClientName, defaultClientVersion: deps.defaultClientVersion, @@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler( handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload), logInfo: (message: string) => deps.logInfo(message), logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), + onSessionStateChanged: deps.onSessionStateChanged, }); } @@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler( getCurrentSession: () => deps.getCurrentSession(), setCurrentSession: (session) => deps.setCurrentSession(session), clearActivePlayback: () => deps.clearActivePlayback(), + onSessionStateChanged: deps.onSessionStateChanged, }); } diff --git a/src/main/runtime/jellyfin-subtitle-cache-io.test.ts b/src/main/runtime/jellyfin-subtitle-cache-io.test.ts new file mode 100644 index 00000000..f0382a45 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-cache-io.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createJellyfinSubtitleCacheIo } from './jellyfin-subtitle-cache-io'; + +test('jellyfin subtitle cache io downloads tracks to temp files and cleans cache dirs', async () => { + const writes: Array<{ filePath: string; bytes: string }> = []; + const removed: Array<{ dir: string; recursive: boolean; force: boolean }> = []; + const cacheIo = createJellyfinSubtitleCacheIo({ + tmpDir: () => '/tmp', + makeTempDir: async (prefix) => { + assert.equal(prefix, '/tmp/subminer-jellyfin-subtitles-'); + return '/tmp/subminer-jellyfin-subtitles-abc'; + }, + writeFile: async (filePath, bytes) => { + writes.push({ filePath, bytes: new TextDecoder().decode(bytes) }); + }, + removeDir: (dir, options) => { + removed.push({ dir, ...options }); + }, + fetch: async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => new TextEncoder().encode('subtitle body').buffer as ArrayBuffer, + }), + }); + + const cached = await cacheIo.cacheSubtitleTrack({ + index: 7, + deliveryUrl: 'https://example.test/Items/1/Subtitles/7/Stream.ass?api_key=secret', + }); + cacheIo.cleanupCachedSubtitles([cached.cleanupDir]); + + assert.deepEqual(cached, { + path: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass', + cleanupDir: '/tmp/subminer-jellyfin-subtitles-abc', + }); + assert.deepEqual(writes, [ + { + filePath: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass', + bytes: 'subtitle body', + }, + ]); + assert.deepEqual(removed, [ + { dir: '/tmp/subminer-jellyfin-subtitles-abc', recursive: true, force: true }, + ]); +}); + +test('jellyfin subtitle cache io removes temp dir when download fails', async () => { + const removed: string[] = []; + const cacheIo = createJellyfinSubtitleCacheIo({ + tmpDir: () => '/tmp', + makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed', + writeFile: async () => {}, + removeDir: (dir) => { + removed.push(dir); + }, + fetch: async () => ({ + ok: false, + status: 500, + arrayBuffer: async () => new ArrayBuffer(0), + }), + }); + + await assert.rejects( + () => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }), + /HTTP 500/, + ); + assert.deepEqual(removed, ['/tmp/subminer-jellyfin-subtitles-failed']); +}); diff --git a/src/main/runtime/jellyfin-subtitle-cache-io.ts b/src/main/runtime/jellyfin-subtitle-cache-io.ts new file mode 100644 index 00000000..d2a0ee98 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-cache-io.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; + +type JellyfinSubtitleCacheTrack = { + index: number; + deliveryUrl?: string | null; +}; + +type JellyfinSubtitleCacheEntry = { + path: string; + cleanupDir: string; +}; + +type FetchResponseLike = { + ok: boolean; + status: number; + arrayBuffer: () => Promise; +}; + +type JellyfinSubtitleCacheIoDeps = { + tmpDir: () => string; + makeTempDir: (prefix: string) => Promise; + writeFile: (filePath: string, bytes: Uint8Array) => Promise; + removeDir: (dir: string, options: { recursive: true; force: true }) => void; + fetch: (url: string) => Promise; +}; + +function getSubtitleExtension(deliveryUrl: string): string { + const urlPath = (() => { + try { + return new URL(deliveryUrl).pathname; + } catch { + return deliveryUrl; + } + })(); + return path.extname(urlPath).slice(0, 16) || '.srt'; +} + +export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps) { + return { + async cacheSubtitleTrack( + track: JellyfinSubtitleCacheTrack, + ): Promise { + if (!track.deliveryUrl) { + throw new Error('Jellyfin subtitle track has no delivery URL'); + } + + const cacheDir = await deps.makeTempDir( + path.join(deps.tmpDir(), 'subminer-jellyfin-subtitles-'), + ); + const subtitlePath = path.join( + cacheDir, + `track-${track.index}${getSubtitleExtension(track.deliveryUrl)}`, + ); + try { + const response = await deps.fetch(track.deliveryUrl); + if (!response.ok) { + throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + await deps.writeFile(subtitlePath, bytes); + } catch (error) { + deps.removeDir(cacheDir, { recursive: true, force: true }); + throw error; + } + return { path: subtitlePath, cleanupDir: cacheDir }; + }, + cleanupCachedSubtitles(dirs: string[]): void { + for (const dir of dirs) { + deps.removeDir(dir, { recursive: true, force: true }); + } + }, + }; +} diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index 389453d7..d6c6a411 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -61,8 +61,22 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa ], getMpvClient: () => ({ requestProperty: async () => [ - { type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true }, - { type: 'sub', id: 6, lang: 'eng', title: 'English', external: true }, + { + type: 'sub', + id: 5, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 6, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, ], }), sendMpvCommand: (command) => commands.push(command), @@ -76,13 +90,225 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa await preload({ session, clientInfo, itemId: 'item-1' }); assert.deepEqual(commands, [ - ['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'], - ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'], ['set_property', 'sid', 5], ['set_property', 'secondary-sid', 6], ]); }); +test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + if (requestCount < 3) { + return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }]; + } + return [ + { type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }, + { + type: 'sub', + id: 5, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 6, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 3); + assert.deepEqual( + commands.filter((command) => command[0] === 'set_property'), + [ + ['set_property', 'sid', 5], + ['set_property', 'secondary-sid', 6], + ], + ); +}); + +test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + if (requestCount < 3) { + return [{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }]; + } + return [ + { type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }, + { + type: 'sub', + id: 42, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 43, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 3); + assert.deepEqual( + commands.filter((command) => command[0] === 'set_property'), + [ + ['set_property', 'sid', 42], + ['set_property', 'secondary-sid', 43], + ], + ); +}); + +test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, + { index: 10, language: 'deu', title: 'German', deliveryUrl: 'https://sub/deu.ass' }, + { index: 12, language: 'rus', title: 'Russian', deliveryUrl: 'https://sub/rus.ass' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + if (requestCount === 1) { + return [ + { + type: 'sub', + id: 11, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ]; + } + return [ + { + type: 'sub', + id: 11, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + { + type: 'sub', + id: 18, + lang: 'deu', + title: 'German', + external: true, + selected: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/10.srt', + }, + { + type: 'sub', + id: 20, + lang: 'rus', + title: 'Russian', + external: true, + selected: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/12.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 2); + assert.deepEqual( + commands.filter((command) => command[0] === 'sub-add'), + [ + ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'Japanese', 'jpn'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/10.srt', 'auto', 'German', 'deu'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'], + ], + ); + assert.deepEqual( + commands.filter((command) => command[0] === 'set_property'), + [['set_property', 'sid', 11]], + ); +}); + +test('preload jellyfin subtitles leaves current track alone when reported japanese track never appears', async () => { + const commands: Array> = []; + const logs: string[] = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + logDebug: (message) => logs.push(message), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 10); + assert.equal( + commands.some( + (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no', + ), + false, + ); + assert.deepEqual(logs, ['Timed out waiting for Jellyfin Japanese subtitle track']); +}); + test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => { const cleanupCalls: string[][] = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( @@ -105,6 +331,34 @@ test('preload jellyfin subtitles cleans previous cached subtitles before a new p assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]); }); +test('preload jellyfin subtitles serializes overlapping preload runs', async () => { + let releaseFirstList!: () => void; + const firstListBlocked = new Promise((resolve) => { + releaseFirstList = resolve; + }); + const listCalls: string[] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => { + listCalls.push(itemId); + if (itemId === 'item-1') { + await firstListBlocked; + } + return []; + }, + }), + ); + + const first = preload({ session, clientInfo, itemId: 'item-1' }); + const second = preload({ session, clientInfo, itemId: 'item-2' }); + await Promise.resolve(); + + assert.deepEqual(listCalls, ['item-1']); + releaseFirstList(); + await Promise.all([first, second]); + assert.deepEqual(listCalls, ['item-1', 'item-2']); +}); + test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => { const cleanupCalls: string[][] = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 7c282409..bebce847 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -23,10 +23,27 @@ type CachedSubtitleTrack = { cleanupDir: string; }; +type CachedExternalSubtitleTrack = CachedSubtitleTrack & { + source: JellyfinSubtitleTrack; +}; + +type MpvSubtitleTrack = { + id: number; + lang: string; + title: string; + external: boolean; + externalFilename: string; +}; + type MpvClientLike = { + connected?: boolean; requestProperty: (name: string) => Promise; }; +const TRACK_SELECTION_INITIAL_WAIT_MS = 250; +const TRACK_SELECTION_RETRY_MS = 150; +const TRACK_SELECTION_MAX_ATTEMPTS = 10; + export type PreloadJellyfinExternalSubtitlesHandler = ((params: { session: JellyfinSession; clientInfo: JellyfinClientInfo; @@ -71,17 +88,12 @@ function isLikelyHearingImpaired(title: string): boolean { } function pickBestTrackId( - tracks: Array<{ - id: number; - lang: string; - title: string; - external: boolean; - }>, + tracks: MpvSubtitleTrack[], languageMatcher: (value: string) => boolean, excludeId: number | null = null, ): number | null { const ranked = tracks - .filter((track) => languageMatcher(track.lang)) + .filter((track) => languageMatcher(track.lang) || languageMatcher(track.title)) .filter((track) => track.id !== excludeId) .map((track) => ({ track, @@ -94,6 +106,119 @@ function pickBestTrackId( return ranked[0]?.track.id ?? null; } +function pickBestCachedTrackId( + tracks: MpvSubtitleTrack[], + cachedTracks: CachedExternalSubtitleTrack[], + sourceMatcher: (value: string) => boolean, + excludeId: number | null = null, +): number | null { + const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track])); + const ranked = tracks + .map((track) => ({ + track, + cached: cachedByPath.get(track.externalFilename), + })) + .filter(({ cached }) => + cached + ? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '') + : false, + ) + .filter(({ track }) => track.id !== excludeId) + .map(({ track, cached }) => { + const title = cached?.source.title || track.title; + return { + track, + score: + (track.external ? 100 : 0) + + (isLikelyHearingImpaired(title) ? -10 : 10) + + (/\bdefault\b/i.test(title) ? 3 : 0), + }; + }) + .sort((a, b) => b.score - a.score); + return ranked[0]?.track.id ?? null; +} + +function isJapaneseTrack(track: MpvSubtitleTrack): boolean { + return isJapanese(track.lang) || isJapanese(track.title); +} + +function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean { + return tracks.some((track) => track.external && isJapaneseTrack(track)); +} + +function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] { + return Array.isArray(trackListRaw) + ? trackListRaw + .filter( + (track): track is Record => + Boolean(track) && + typeof track === 'object' && + track.type === 'sub' && + typeof track.id === 'number', + ) + .map((track) => ({ + id: track.id as number, + lang: String(track.lang || ''), + title: String(track.title || ''), + external: track.external === true, + externalFilename: String(track['external-filename'] || ''), + })) + : []; +} + +function hasExpectedExternalSubtitleTracks( + tracks: MpvSubtitleTrack[], + expectedExternalFilenames: string[], +): boolean { + if (expectedExternalFilenames.length === 0) { + return true; + } + const loadedExternalFilenames = new Set( + tracks + .filter((track) => track.externalFilename) + .map((track) => track.externalFilename), + ); + return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath)); +} + +async function readMpvSubtitleTracks(deps: { + getMpvClient: () => MpvClientLike | null; +}): Promise { + const client = deps.getMpvClient(); + if (!client || client.connected === false) { + return null; + } + const trackListRaw = await client.requestProperty('track-list'); + return parseMpvSubtitleTracks(trackListRaw); +} + +async function waitForPreferredSubtitleTracks( + deps: { + getMpvClient: () => MpvClientLike | null; + wait: (ms: number) => Promise; + }, + shouldWaitForExternalJapanese: boolean, + expectedExternalFilenames: string[], +): Promise { + let subtitleTracks: MpvSubtitleTrack[] = []; + for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) { + const nextTracks = await readMpvSubtitleTracks(deps); + if (nextTracks !== null) { + subtitleTracks = nextTracks; + if ( + (!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) && + hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames) + ) { + return subtitleTracks; + } + } + if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) { + await deps.wait(TRACK_SELECTION_RETRY_MS); + } + } + return subtitleTracks; +} + export function createPreloadJellyfinExternalSubtitlesHandler(deps: { listJellyfinSubtitleTracks: ( session: JellyfinSession, @@ -108,6 +233,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { logDebug: (message: string, error: unknown) => void; }): PreloadJellyfinExternalSubtitlesHandler { const activeCacheDirs = new Set(); + let preloadQueue: Promise = Promise.resolve(); function cleanupActiveCache(): void { const dirs = [...activeCacheDirs]; @@ -116,7 +242,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { deps.cleanupCachedSubtitles(dirs); } - const preload = async (params: { + const runPreload = async (params: { session: JellyfinSession; clientInfo: JellyfinClientInfo; itemId: string; @@ -136,6 +262,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { await deps.wait(300); const seenUrls = new Set(); + const cachedTracks: CachedExternalSubtitleTrack[] = []; for (const track of externalTracks) { if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) { continue; @@ -145,36 +272,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { const label = labelBase || `Jellyfin Subtitle ${track.index}`; const cached = await deps.cacheSubtitleTrack(track); activeCacheDirs.add(cached.cleanupDir); - deps.sendMpvCommand(['sub-add', cached.path, 'cached', label, track.language || '']); + cachedTracks.push({ ...cached, source: track }); + deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']); } - await deps.wait(250); - const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list'); - const subtitleTracks = Array.isArray(trackListRaw) - ? trackListRaw - .filter( - (track): track is Record => - Boolean(track) && - typeof track === 'object' && - track.type === 'sub' && - typeof track.id === 'number', - ) - .map((track) => ({ - id: track.id as number, - lang: String(track.lang || ''), - title: String(track.title || ''), - external: track.external === true, - })) - : []; + await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS); + const shouldWaitForExternalJapanese = externalTracks.some( + (track) => isJapanese(track.language || '') || isJapanese(track.title || ''), + ); + const subtitleTracks = await waitForPreferredSubtitleTracks( + deps, + shouldWaitForExternalJapanese, + cachedTracks.map((track) => track.path), + ); + if ( + shouldWaitForExternalJapanese && + (!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks)) + ) { + deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', { + itemId: params.itemId, + }); + return; + } - const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); + const japanesePrimaryId = + pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ?? + pickBestTrackId(subtitleTracks ?? [], isJapanese); if (japanesePrimaryId !== null) { deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); } else { deps.sendMpvCommand(['set_property', 'sid', 'no']); } - const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); + const englishSecondaryId = + pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ?? + pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId); if (englishSecondaryId !== null) { deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); } @@ -183,6 +315,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { } }; + const preload = (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; + }): Promise => { + preloadQueue = preloadQueue.then( + () => runPreload(params), + () => runPreload(params), + ); + return preloadQueue; + }; + return Object.assign(preload, { cleanupCachedSubtitles: cleanupActiveCache, }); diff --git a/src/main/runtime/jellyfin-tray-discovery.test.ts b/src/main/runtime/jellyfin-tray-discovery.test.ts index cd23a841..7c627330 100644 --- a/src/main/runtime/jellyfin-tray-discovery.test.ts +++ b/src/main/runtime/jellyfin-tray-discovery.test.ts @@ -194,6 +194,124 @@ test('stops active discovery from tray', async () => { ]); }); +test('uses checked tray state to start discovery instead of blind toggling', async () => { + const calls: string[] = []; + let session: { advertiseNow: () => Promise } | null = null; + + await toggleJellyfinDiscoveryFromTray( + { + getRemoteSession: () => session, + stopRemoteSession: () => calls.push('stop'), + startRemoteSession: async (options) => { + assert.deepEqual(options, { explicit: true }); + calls.push('start'); + session = { + advertiseNow: async () => { + calls.push('advertise'); + return true; + }, + }; + }, + refreshTrayMenu: () => calls.push('refresh'), + logger: { + info: (message) => calls.push(`info:${message}`), + warn: (message) => calls.push(`warn:${message}`), + error: (message) => calls.push(`error:${message}`), + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + }, + { desiredActive: true }, + ); + + assert.deepEqual(calls, [ + 'start', + 'advertise', + 'info:Jellyfin discovery started; cast target is visible in server sessions.', + 'osd:Jellyfin discovery started', + 'refresh', + ]); +}); + +test('uses unchecked tray state to stop discovery without visibility probing', async () => { + const calls: string[] = []; + + await toggleJellyfinDiscoveryFromTray( + { + getRemoteSession: () => ({ + advertiseNow: async () => { + calls.push('advertise'); + return true; + }, + }), + stopRemoteSession: () => calls.push('stop'), + startRemoteSession: async () => { + calls.push('start'); + }, + refreshTrayMenu: () => calls.push('refresh'), + logger: { + info: (message) => calls.push(`info:${message}`), + warn: (message) => calls.push(`warn:${message}`), + error: (message) => calls.push(`error:${message}`), + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + }, + { desiredActive: false }, + ); + + assert.deepEqual(calls, [ + 'stop', + 'info:Jellyfin discovery stopped.', + 'osd:Jellyfin discovery stopped', + 'refresh', + ]); +}); + +test('restarts active discovery when current session is not visible', async () => { + const calls: string[] = []; + let session: { advertiseNow: () => Promise } | null = { + advertiseNow: async () => { + calls.push('advertise-stale'); + return false; + }, + }; + + await toggleJellyfinDiscoveryFromTray({ + getRemoteSession: () => session, + stopRemoteSession: () => { + calls.push('stop'); + session = null; + }, + startRemoteSession: async (options) => { + assert.deepEqual(options, { explicit: true }); + calls.push('start'); + session = { + advertiseNow: async () => { + calls.push('advertise-fresh'); + return true; + }, + }; + }, + refreshTrayMenu: () => calls.push('refresh'), + logger: { + info: (message) => calls.push(`info:${message}`), + warn: (message) => calls.push(`warn:${message}`), + error: (message) => calls.push(`error:${message}`), + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + }); + + assert.deepEqual(calls, [ + 'advertise-stale', + 'warn:Jellyfin discovery was active but not visible; restarting.', + 'stop', + 'start', + 'advertise-fresh', + 'info:Jellyfin discovery started; cast target is visible in server sessions.', + 'osd:Jellyfin discovery started', + 'refresh', + ]); +}); + test('warns and refreshes tray when explicit discovery cannot create a session', async () => { const calls: string[] = []; diff --git a/src/main/runtime/jellyfin-tray-discovery.ts b/src/main/runtime/jellyfin-tray-discovery.ts index 1611db30..79ffa033 100644 --- a/src/main/runtime/jellyfin-tray-discovery.ts +++ b/src/main/runtime/jellyfin-tray-discovery.ts @@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray, + options: { desiredActive?: boolean } = {}, ): Promise { try { const activeSession = deps.getRemoteSession(); - if (activeSession) { - deps.stopRemoteSession(); - deps.logger.info('Jellyfin discovery stopped.'); - deps.showMpvOsd('Jellyfin discovery stopped'); + if (options.desiredActive === false) { + if (activeSession) { + deps.stopRemoteSession(); + deps.logger.info('Jellyfin discovery stopped.'); + deps.showMpvOsd('Jellyfin discovery stopped'); + } return; } + if (activeSession) { + let visible = false; + try { + visible = await activeSession.advertiseNow(); + } catch { + deps.logger.warn('Jellyfin discovery visibility check failed; restarting.'); + } + + if (visible) { + if (options.desiredActive === true) { + deps.logger.info('Jellyfin discovery already active.'); + } else { + deps.stopRemoteSession(); + deps.logger.info('Jellyfin discovery stopped.'); + deps.showMpvOsd('Jellyfin discovery stopped'); + } + return; + } + + deps.logger.warn('Jellyfin discovery was active but not visible; restarting.'); + deps.stopRemoteSession(); + } + await deps.startRemoteSession({ explicit: true }); const remoteSession = deps.getRemoteSession(); if (!remoteSession) { diff --git a/src/main/runtime/subtitle-prefetch-runtime.test.ts b/src/main/runtime/subtitle-prefetch-runtime.test.ts index 07080896..35214056 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.test.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.test.ts @@ -57,3 +57,33 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable cleanup: resolved?.cleanup, }); }); + +test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => { + let extracted = false; + const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ + getFfmpegPath: () => 'ffmpeg-custom', + extractInternalSubtitleTrack: async () => { + extracted = true; + return { + path: '/tmp/subminer-sidebar-123/track_7.ass', + cleanup: async () => {}, + }; + }, + }); + + const resolved = await resolveSource({ + currentExternalFilenameRaw: null, + currentTrackRaw: { + type: 'sub', + id: 3, + 'ff-index': 7, + codec: 'ass', + }, + trackListRaw: [], + sidRaw: 3, + videoPath: 'http://jellyfin.local/Videos/movie/stream?static=true', + }); + + assert.equal(resolved, null); + assert.equal(extracted, false); +}); diff --git a/src/main/runtime/subtitle-prefetch-runtime.ts b/src/main/runtime/subtitle-prefetch-runtime.ts index 27da41d4..3576582b 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.ts @@ -28,6 +28,15 @@ function parseTrackId(value: unknown): number | null { return null; } +function isRemoteMediaPath(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + function getActiveSubtitleTrack( currentTrackRaw: unknown, trackListRaw: unknown, @@ -104,6 +113,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: { return { path: externalFilename, sourceKey: externalFilename }; } + if (isRemoteMediaPath(input.videoPath)) { + return null; + } + const extracted = await deps.extractInternalSubtitleTrack( deps.getFfmpegPath(), input.videoPath, diff --git a/src/main/runtime/tray-lifecycle.test.ts b/src/main/runtime/tray-lifecycle.test.ts index 1d6bbd9d..fe9fc1c3 100644 --- a/src/main/runtime/tray-lifecycle.test.ts +++ b/src/main/runtime/tray-lifecycle.test.ts @@ -43,6 +43,63 @@ test('ensure tray updates menu when tray already exists', () => { assert.deepEqual(calls, ['set-menu']); }); +test('ensure tray refreshes existing tray menu on linux with setContextMenu', () => { + const calls: string[] = []; + let trayRef: unknown = { + setContextMenu: () => calls.push('old-set-menu'), + setToolTip: () => calls.push('old-set-tooltip'), + on: () => calls.push('old-bind-click'), + destroy: () => calls.push('old-destroy'), + }; + + const ensureTray = createEnsureTrayHandler({ + getTray: () => trayRef as never, + setTray: (tray) => { + trayRef = tray; + calls.push(tray ? 'set-new-tray' : 'clear-tray'); + }, + buildTrayMenu: () => ({ id: 'menu' }), + resolveTrayIconPath: () => '/tmp/icon.png', + createImageFromPath: () => + ({ + isEmpty: () => false, + resize: (options: { width: number; height: number }) => { + calls.push(`resize:${options.width}x${options.height}`); + return { + isEmpty: () => false, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }; + }, + setTemplateImage: () => {}, + }) as never, + createEmptyImage: () => + ({ + isEmpty: () => true, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }) as never, + createTray: () => + ({ + setContextMenu: () => calls.push('new-set-menu'), + setToolTip: () => calls.push('new-set-tooltip'), + on: () => calls.push('new-bind-click'), + destroy: () => calls.push('new-destroy'), + }) as never, + trayTooltip: 'SubMiner', + platform: 'linux', + logWarn: () => calls.push('warn'), + ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'), + }); + + ensureTray(); + assert.deepEqual(calls, ['old-set-menu']); +}); + test('ensure tray creates new tray and binds click handler', () => { const calls: string[] = []; let trayRef: unknown = null; diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index a3f37872..e36d0113 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => { let initialized = false; const buildTemplate = createBuildTrayMenuTemplateHandler({ buildTrayMenuTemplateRuntime: (handlers) => { + calls.push(`platform:${handlers.platform}`); handlers.openSessionHelp(); handlers.openTexthookerInBrowser(); calls.push(`show-texthooker:${handlers.showTexthookerPage}`); @@ -50,7 +51,7 @@ test('build tray template handler wires actions and init guards', () => { handlers.openYomitanSettings(); handlers.openConfigSettings(); handlers.openJellyfinSetup(); - handlers.toggleJellyfinDiscovery(); + handlers.toggleJellyfinDiscovery(true); handlers.openAnilistSetup(); handlers.checkForUpdates(); handlers.quitApp(); @@ -72,9 +73,10 @@ test('build tray template handler wires actions and init guards', () => { openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, - toggleJellyfinDiscovery: async () => { - calls.push('jellyfin-discovery'); + toggleJellyfinDiscovery: async (checked) => { + calls.push(`jellyfin-discovery:${checked}`); }, + platform: 'linux', openAnilistSetupWindow: () => calls.push('anilist'), checkForUpdates: () => calls.push('updates'), quitApp: () => calls.push('quit'), @@ -83,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => { const template = buildTemplate(); assert.deepEqual(template, [{ label: 'ok' }]); assert.deepEqual(calls, [ + 'platform:linux', 'init', 'help', 'texthooker', @@ -92,7 +95,7 @@ test('build tray template handler wires actions and init guards', () => { 'yomitan', 'configuration', 'jellyfin', - 'jellyfin-discovery', + 'jellyfin-discovery:true', 'anilist', 'updates', 'quit', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 94552903..e60d4db3 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -37,6 +37,7 @@ export function shouldShowTexthookerTrayEntry(config: { export function createBuildTrayMenuTemplateHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { + platform?: string; openSessionHelp: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; @@ -49,7 +50,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; - toggleJellyfinDiscovery: () => void; + toggleJellyfinDiscovery: (checked: boolean) => void; openAnilistSetup: () => void; checkForUpdates: () => void; quitApp: () => void; @@ -67,13 +68,15 @@ export function createBuildTrayMenuTemplateHandler(deps: { openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; - toggleJellyfinDiscovery: () => void | Promise; + toggleJellyfinDiscovery: (checked: boolean) => void | Promise; + platform?: string; openAnilistSetupWindow: () => void; checkForUpdates: () => void; quitApp: () => void; }) { return (): TMenuItem[] => { return deps.buildTrayMenuTemplateRuntime({ + platform: deps.platform, openSessionHelp: () => { if (!deps.isOverlayRuntimeInitialized()) { deps.initializeOverlayRuntime(); @@ -103,8 +106,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { }, showJellyfinDiscovery: deps.isJellyfinConfigured(), jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(), - toggleJellyfinDiscovery: () => { - void deps.toggleJellyfinDiscovery(); + toggleJellyfinDiscovery: (checked) => { + void deps.toggleJellyfinDiscovery(checked); }, openAnilistSetup: () => { deps.openAnilistSetupWindow(); diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 8206c999..8188018f 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -35,15 +35,18 @@ test('tray main deps builders return mapped handlers', () => { openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, - toggleJellyfinDiscovery: () => { - calls.push('jellyfin-discovery'); + toggleJellyfinDiscovery: (checked) => { + calls.push(`jellyfin-discovery:${checked}`); }, + platform: 'linux', openAnilistSetupWindow: () => calls.push('anilist'), checkForUpdates: () => calls.push('updates'), quitApp: () => calls.push('quit'), })(); + assert.equal(menuDeps.platform, 'linux'); const template = menuDeps.buildTrayMenuTemplateRuntime({ + platform: menuDeps.platform, openSessionHelp: () => calls.push('open-help'), openTexthookerInBrowser: () => calls.push('open-texthooker'), showTexthookerPage: true, @@ -56,7 +59,7 @@ test('tray main deps builders return mapped handlers', () => { openJellyfinSetup: () => calls.push('open-jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, - toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'), + toggleJellyfinDiscovery: (checked) => calls.push(`open-jellyfin-discovery:${checked}`), openAnilistSetup: () => calls.push('open-anilist'), checkForUpdates: () => calls.push('open-updates'), quitApp: () => calls.push('quit-app'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index bea49cd4..f318843b 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -27,6 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: { export function createBuildTrayMenuTemplateMainDepsHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { + platform?: string; openSessionHelp: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; @@ -39,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; - toggleJellyfinDiscovery: () => void; + toggleJellyfinDiscovery: (checked: boolean) => void; openAnilistSetup: () => void; checkForUpdates: () => void; quitApp: () => void; @@ -57,13 +58,15 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; - toggleJellyfinDiscovery: () => void | Promise; + toggleJellyfinDiscovery: (checked: boolean) => void | Promise; + platform?: string; openAnilistSetupWindow: () => void; checkForUpdates: () => void; quitApp: () => void; }) { return () => ({ buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime, + platform: deps.platform, initializeOverlayRuntime: deps.initializeOverlayRuntime, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, openSessionHelpModal: deps.openSessionHelpModal, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index f79ae128..74182f6c 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -41,7 +41,7 @@ test('tray menu template contains expected entries and handlers', () => { openJellyfinSetup: () => calls.push('jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, - toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'), + toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`), openAnilistSetup: () => calls.push('anilist'), checkForUpdates: () => calls.push('updates'), quitApp: () => calls.push('quit'), @@ -60,7 +60,7 @@ test('tray menu template contains expected entries and handlers', () => { const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery'); assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.checked, false); - discovery?.click?.(); + discovery?.click?.({ checked: true }); template[0]!.click?.(); assert.equal(template[1]!.label, 'Open Texthooker'); template[1]!.click?.(); @@ -70,7 +70,7 @@ test('tray menu template contains expected entries and handlers', () => { template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); template[11]!.click?.(); assert.deepEqual(calls, [ - 'jellyfin-discovery', + 'jellyfin-discovery:true', 'help', 'texthooker', 'updates', @@ -155,3 +155,29 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.checked, true); }); + +test('tray menu template renders a visible linux discovery check mark when active', () => { + const template = buildTrayMenuTemplateRuntime({ + platform: 'linux', + openSessionHelp: () => undefined, + openTexthookerInBrowser: () => undefined, + showTexthookerPage: true, + openFirstRunSetup: () => undefined, + showFirstRunSetup: false, + openWindowsMpvLauncherSetup: () => undefined, + showWindowsMpvLauncherSetup: false, + openYomitanSettings: () => undefined, + openConfigSettings: () => undefined, + openJellyfinSetup: () => undefined, + showJellyfinDiscovery: true, + jellyfinDiscoveryActive: true, + toggleJellyfinDiscovery: () => undefined, + openAnilistSetup: () => undefined, + checkForUpdates: () => undefined, + quitApp: () => undefined, + }); + + const discovery = template.find((entry) => entry.label === '✓ Jellyfin Discovery'); + assert.equal(discovery?.type, 'checkbox'); + assert.equal(discovery?.checked, true); +}); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index cbb40844..ba71c5a0 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -30,6 +30,7 @@ export function resolveTrayIconPathRuntime(deps: { } export type TrayMenuActionHandlers = { + platform?: string; openSessionHelp: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; @@ -42,19 +43,28 @@ export type TrayMenuActionHandlers = { openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; - toggleJellyfinDiscovery: () => void; + toggleJellyfinDiscovery: (checked: boolean) => void; openAnilistSetup: () => void; checkForUpdates: () => void; quitApp: () => void; }; +type TrayMenuClickItem = { + checked?: boolean; +}; + export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{ label?: string; type?: 'separator' | 'checkbox'; checked?: boolean; enabled?: boolean; - click?: () => void; + click?: (menuItem?: TrayMenuClickItem) => void; }> { + const jellyfinDiscoveryLabel = + handlers.platform === 'linux' && handlers.jellyfinDiscoveryActive + ? '✓ Jellyfin Discovery' + : 'Jellyfin Discovery'; + return [ { label: 'Open Help', @@ -99,11 +109,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): ...(handlers.showJellyfinDiscovery ? [ { - label: 'Jellyfin Discovery', + label: jellyfinDiscoveryLabel, type: 'checkbox' as const, checked: handlers.jellyfinDiscoveryActive, enabled: true, - click: handlers.toggleJellyfinDiscovery, + click: (menuItem?: TrayMenuClickItem) => { + const checked = + typeof menuItem?.checked === 'boolean' + ? menuItem.checked + : !handlers.jellyfinDiscoveryActive; + handlers.toggleJellyfinDiscovery(checked); + }, }, ] : []), diff --git a/src/renderer/modals/subsync.test.ts b/src/renderer/modals/subsync.test.ts index 26aea7bf..b2211422 100644 --- a/src/renderer/modals/subsync.test.ts +++ b/src/renderer/modals/subsync.test.ts @@ -244,3 +244,29 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () => harness.restoreGlobals(); } }); + +test('subsync modal ignores enter submission when no sync engine is available', async () => { + let runCalls = 0; + const harness = createTestHarness(async () => { + runCalls += 1; + return { ok: true, message: 'ok' }; + }); + + try { + harness.modal.openSubsyncModal({ + sourceTracks: [], + ffsubsyncAvailable: false, + }); + + harness.modal.handleSubsyncKeydown({ + key: 'Enter', + preventDefault: () => {}, + } as KeyboardEvent); + await flushMicrotasks(); + + assert.equal(runCalls, 0); + assert.equal(harness.ctx.state.subsyncModalOpen, true); + } finally { + harness.restoreGlobals(); + } +}); diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts index fd8e6c86..9812df96 100644 --- a/src/renderer/modals/subsync.ts +++ b/src/renderer/modals/subsync.ts @@ -105,8 +105,16 @@ export function createSubsyncModal( async function runSubsyncManualFromModal(): Promise { if (ctx.state.subsyncSubmitting) return; + if (ctx.dom.subsyncRunButton.disabled) return; - const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync'; + const useAlass = ctx.dom.subsyncEngineAlass.checked; + const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked; + if (!useAlass && !useFfsubsync) { + setSubsyncStatus('No sync engine available for current media.', true); + return; + } + + const engine = useAlass ? 'alass' : 'ffsubsync'; const sourceTrackId = engine === 'alass' && ctx.dom.subsyncSourceSelect.value ? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10) diff --git a/src/types/config.ts b/src/types/config.ts index 8921cbe8..b95896fb 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -300,14 +300,10 @@ export interface ResolvedConfig { serverUrl: string; recentServers: string[]; username: string; - deviceId: string; - clientName: string; - clientVersion: string; defaultLibraryId: string; remoteControlEnabled: boolean; remoteControlAutoConnect: boolean; autoAnnounce: boolean; - remoteControlDeviceName: string; pullPictures: boolean; iconCacheDir: string; directPlayPreferred: boolean; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index d0e18a55..340395a3 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -87,14 +87,10 @@ export interface JellyfinConfig { serverUrl?: string; recentServers?: string[]; username?: string; - deviceId?: string; - clientName?: string; - clientVersion?: string; defaultLibraryId?: string; remoteControlEnabled?: boolean; remoteControlAutoConnect?: boolean; autoAnnounce?: boolean; - remoteControlDeviceName?: string; pullPictures?: boolean; iconCacheDir?: string; directPlayPreferred?: boolean;