mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Kept Jellyfin picker library discovery working when the running app log level is above info.
|
||||||
+81
-151
@@ -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.
|
* 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
|
// Visible Overlay Auto-Start
|
||||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"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.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false
|
"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.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"annotationWebsocket": {
|
"annotationWebsocket": {
|
||||||
"enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false
|
"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.
|
}, // 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.
|
// Hot-reload: logging.level applies live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"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.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -82,66 +81,66 @@
|
|||||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||||
"leftTrigger": 6, // Raw button index used for controller L2 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.
|
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"toggleLookup": {
|
"toggleLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"closeLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"toggleKeyboardOnlyMode": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"mineCard": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"quitMpv": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"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.
|
}, // 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": {
|
"nextAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"playCurrentAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"toggleMpvPause": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"leftStickHorizontal": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
"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.
|
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"leftStickVertical": {
|
"leftStickVertical": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
"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.
|
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"rightStickHorizontal": {
|
"rightStickHorizontal": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
"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.
|
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"rightStickVertical": {
|
"rightStickVertical": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"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
|
"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.
|
}, // 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.
|
}, // 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.
|
}, // 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
|
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
||||||
"yomitanExtension": true, // Warm up Yomitan extension 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
|
"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.
|
}, // 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
|
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||||
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
|
"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.
|
}, // Automatic update check behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -193,7 +192,7 @@
|
|||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
"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.
|
"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.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -205,122 +204,76 @@
|
|||||||
"keybindings": [
|
"keybindings": [
|
||||||
{
|
{
|
||||||
"key": "Space", // Key setting.
|
"key": "Space", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "pause"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"pause"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyF", // Key setting.
|
"key": "KeyF", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "fullscreen"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"fullscreen"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyJ", // Key setting.
|
"key": "KeyJ", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "sid"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"sid"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyJ", // Key setting.
|
"key": "Shift+KeyJ", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "secondary-sid"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"secondary-sid"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowRight", // Key setting.
|
"key": "ArrowRight", // Key setting.
|
||||||
"command": [
|
"command": ["seek", 5], // Command setting.
|
||||||
"seek",
|
|
||||||
5
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowLeft", // Key setting.
|
"key": "ArrowLeft", // Key setting.
|
||||||
"command": [
|
"command": ["seek", -5], // Command setting.
|
||||||
"seek",
|
|
||||||
-5
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowUp", // Key setting.
|
"key": "ArrowUp", // Key setting.
|
||||||
"command": [
|
"command": ["seek", 60], // Command setting.
|
||||||
"seek",
|
|
||||||
60
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowDown", // Key setting.
|
"key": "ArrowDown", // Key setting.
|
||||||
"command": [
|
"command": ["seek", -60], // Command setting.
|
||||||
"seek",
|
|
||||||
-60
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyH", // Key setting.
|
"key": "Shift+KeyH", // Key setting.
|
||||||
"command": [
|
"command": ["sub-seek", -1], // Command setting.
|
||||||
"sub-seek",
|
|
||||||
-1
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyL", // Key setting.
|
"key": "Shift+KeyL", // Key setting.
|
||||||
"command": [
|
"command": ["sub-seek", 1], // Command setting.
|
||||||
"sub-seek",
|
|
||||||
1
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketRight", // Key setting.
|
"key": "Shift+BracketRight", // Key setting.
|
||||||
"command": [
|
"command": ["__sub-delay-next-line"], // Command setting.
|
||||||
"__sub-delay-next-line"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketLeft", // Key setting.
|
"key": "Shift+BracketLeft", // Key setting.
|
||||||
"command": [
|
"command": ["__sub-delay-prev-line"], // Command setting.
|
||||||
"__sub-delay-prev-line"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Alt+KeyC", // Key setting.
|
"key": "Ctrl+Alt+KeyC", // Key setting.
|
||||||
"command": [
|
"command": ["__youtube-picker-open"], // Command setting.
|
||||||
"__youtube-picker-open"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Alt+KeyP", // Key setting.
|
"key": "Ctrl+Alt+KeyP", // Key setting.
|
||||||
"command": [
|
"command": ["__playlist-browser-open"], // Command setting.
|
||||||
"__playlist-browser-open"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||||
"command": [
|
"command": ["__replay-subtitle"], // Command setting.
|
||||||
"__replay-subtitle"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Shift+KeyL", // Key setting.
|
"key": "Ctrl+Shift+KeyL", // Key setting.
|
||||||
"command": [
|
"command": ["__play-next-subtitle"], // Command setting.
|
||||||
"__play-next-subtitle"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyQ", // Key setting.
|
"key": "KeyQ", // Key setting.
|
||||||
"command": [
|
"command": ["quit"], // Command setting.
|
||||||
"quit"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+KeyW", // Key setting.
|
"key": "Ctrl+KeyW", // Key setting.
|
||||||
"command": [
|
"command": ["quit"], // Command setting.
|
||||||
"quit"
|
},
|
||||||
] // Command setting.
|
|
||||||
}
|
|
||||||
], // Default and custom keybindings that are merged with built-in defaults.
|
], // Default and custom keybindings that are merged with built-in defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -332,7 +285,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
"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
|
"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.
|
}, // 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.
|
"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.
|
"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.
|
"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.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -352,7 +305,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"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.
|
}, // 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.
|
"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.
|
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||||
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color 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.
|
}, // 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
|
"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
|
"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.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#8bd5ca", // N4 setting.
|
"N4": "#8bd5ca", // N4 setting.
|
||||||
"N5": "#8aadf4" // N5 setting.
|
"N5": "#8aadf4", // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"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
|
"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
|
"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`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
"#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.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"css": {
|
"css": {
|
||||||
@@ -423,9 +370,9 @@
|
|||||||
"font-kerning": "normal", // Font kerning setting.
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
"text-rendering": "geometricPrecision", // Text rendering 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.
|
"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.
|
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||||
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||||
} // Secondary setting.
|
}, // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -450,8 +397,8 @@
|
|||||||
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
"--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-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-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.
|
"--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.
|
}, // 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.
|
}, // 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.
|
"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.
|
"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.
|
"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.
|
}, // 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
|
"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.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
"port": 8766, // Bind port 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.
|
}, // Proxy setting.
|
||||||
"tags": [
|
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"SubMiner"
|
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
"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.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||||
"model": "", // Optional model override for Anki AI translation/enrichment flows.
|
"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.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
"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
|
"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.
|
"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.
|
"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.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"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
|
"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.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"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
|
"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
|
"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
|
"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.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
"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.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"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.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
"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.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"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
|
"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
|
"deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -556,7 +501,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"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.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -565,10 +510,7 @@
|
|||||||
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
|
||||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -589,9 +531,9 @@
|
|||||||
"collapsibleSections": {
|
"collapsibleSections": {
|
||||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
"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
|
"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
|
"voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
|
||||||
} // Collapsible sections setting.
|
}, // Collapsible sections setting.
|
||||||
} // Character dictionary setting.
|
}, // Character dictionary setting.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -602,7 +544,7 @@
|
|||||||
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"yomitan": {
|
"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.
|
}, // 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
|
"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.
|
"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
|
"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.
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -636,27 +578,15 @@
|
|||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
"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.
|
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. 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
|
"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
|
"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.
|
"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
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
||||||
"mkv",
|
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
||||||
"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.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -668,7 +598,7 @@
|
|||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"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
|
"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.
|
"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.
|
}, // 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.
|
"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.
|
"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.
|
"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.
|
}, // Retention setting.
|
||||||
"lifetimeSummaries": {
|
"lifetimeSummaries": {
|
||||||
"global": true, // Maintain global lifetime stats rows. Values: true | false
|
"global": true, // Maintain global lifetime stats rows. Values: true | false
|
||||||
"anime": true, // Maintain per-anime 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
|
"media": true, // Maintain per-media lifetime stats rows. Values: true | false
|
||||||
} // Lifetime summaries setting.
|
}, // Lifetime summaries setting.
|
||||||
}, // Enable/disable immersion tracking.
|
}, // 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.
|
"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.
|
"serverPort": 6969, // Port for the stats HTTP server.
|
||||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
"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
|
"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.
|
}, // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1258,7 +1258,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
"remoteControlEnabled": true,
|
"remoteControlEnabled": true,
|
||||||
"remoteControlAutoConnect": true,
|
"remoteControlAutoConnect": true,
|
||||||
"autoAnnounce": false,
|
"autoAnnounce": false,
|
||||||
"remoteControlDeviceName": "SubMiner",
|
|
||||||
"defaultLibraryId": "",
|
"defaultLibraryId": "",
|
||||||
"directPlayPreferred": true,
|
"directPlayPreferred": true,
|
||||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
"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 |
|
| `serverUrl` | string (URL) | Jellyfin server base URL |
|
||||||
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
|
| `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` |
|
| `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 |
|
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
|
||||||
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
|
| `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`) |
|
| `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`) |
|
| `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 |
|
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
|
||||||
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
|
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
|
||||||
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
|
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
|
||||||
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `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=<backend>` on launcher/app invocations when needed.
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
|||||||
"remoteControlEnabled": true,
|
"remoteControlEnabled": true,
|
||||||
"remoteControlAutoConnect": true,
|
"remoteControlAutoConnect": true,
|
||||||
"autoAnnounce": false,
|
"autoAnnounce": false,
|
||||||
"remoteControlDeviceName": "SubMiner",
|
|
||||||
"defaultLibraryId": "",
|
"defaultLibraryId": "",
|
||||||
"pullPictures": false,
|
"pullPictures": false,
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
||||||
@@ -50,7 +49,7 @@ subminer jellyfin -l \
|
|||||||
--password 'your-password'
|
--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:
|
3. List libraries:
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
|||||||
subminer jellyfin -d
|
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:
|
Stop discovery session/app:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* 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
|
// Visible Overlay Auto-Start
|
||||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"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.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false
|
"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.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"annotationWebsocket": {
|
"annotationWebsocket": {
|
||||||
"enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false
|
"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.
|
}, // 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.
|
// Hot-reload: logging.level applies live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"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.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -82,66 +81,66 @@
|
|||||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||||
"leftTrigger": 6, // Raw button index used for controller L2 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.
|
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"toggleLookup": {
|
"toggleLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"closeLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"toggleKeyboardOnlyMode": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"mineCard": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"quitMpv": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"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.
|
}, // 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": {
|
"nextAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"playCurrentAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"toggleMpvPause": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"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.
|
}, // 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": {
|
"leftStickHorizontal": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
"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.
|
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"leftStickVertical": {
|
"leftStickVertical": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
"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.
|
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"rightStickHorizontal": {
|
"rightStickHorizontal": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
"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.
|
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"rightStickVertical": {
|
"rightStickVertical": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"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
|
"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.
|
}, // 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.
|
}, // 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.
|
}, // 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
|
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
||||||
"yomitanExtension": true, // Warm up Yomitan extension 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
|
"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.
|
}, // 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
|
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||||
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
|
"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.
|
}, // Automatic update check behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -193,7 +192,7 @@
|
|||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
"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.
|
"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.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -205,122 +204,76 @@
|
|||||||
"keybindings": [
|
"keybindings": [
|
||||||
{
|
{
|
||||||
"key": "Space", // Key setting.
|
"key": "Space", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "pause"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"pause"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyF", // Key setting.
|
"key": "KeyF", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "fullscreen"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"fullscreen"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyJ", // Key setting.
|
"key": "KeyJ", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "sid"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"sid"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyJ", // Key setting.
|
"key": "Shift+KeyJ", // Key setting.
|
||||||
"command": [
|
"command": ["cycle", "secondary-sid"], // Command setting.
|
||||||
"cycle",
|
|
||||||
"secondary-sid"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowRight", // Key setting.
|
"key": "ArrowRight", // Key setting.
|
||||||
"command": [
|
"command": ["seek", 5], // Command setting.
|
||||||
"seek",
|
|
||||||
5
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowLeft", // Key setting.
|
"key": "ArrowLeft", // Key setting.
|
||||||
"command": [
|
"command": ["seek", -5], // Command setting.
|
||||||
"seek",
|
|
||||||
-5
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowUp", // Key setting.
|
"key": "ArrowUp", // Key setting.
|
||||||
"command": [
|
"command": ["seek", 60], // Command setting.
|
||||||
"seek",
|
|
||||||
60
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ArrowDown", // Key setting.
|
"key": "ArrowDown", // Key setting.
|
||||||
"command": [
|
"command": ["seek", -60], // Command setting.
|
||||||
"seek",
|
|
||||||
-60
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyH", // Key setting.
|
"key": "Shift+KeyH", // Key setting.
|
||||||
"command": [
|
"command": ["sub-seek", -1], // Command setting.
|
||||||
"sub-seek",
|
|
||||||
-1
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyL", // Key setting.
|
"key": "Shift+KeyL", // Key setting.
|
||||||
"command": [
|
"command": ["sub-seek", 1], // Command setting.
|
||||||
"sub-seek",
|
|
||||||
1
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketRight", // Key setting.
|
"key": "Shift+BracketRight", // Key setting.
|
||||||
"command": [
|
"command": ["__sub-delay-next-line"], // Command setting.
|
||||||
"__sub-delay-next-line"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketLeft", // Key setting.
|
"key": "Shift+BracketLeft", // Key setting.
|
||||||
"command": [
|
"command": ["__sub-delay-prev-line"], // Command setting.
|
||||||
"__sub-delay-prev-line"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Alt+KeyC", // Key setting.
|
"key": "Ctrl+Alt+KeyC", // Key setting.
|
||||||
"command": [
|
"command": ["__youtube-picker-open"], // Command setting.
|
||||||
"__youtube-picker-open"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Alt+KeyP", // Key setting.
|
"key": "Ctrl+Alt+KeyP", // Key setting.
|
||||||
"command": [
|
"command": ["__playlist-browser-open"], // Command setting.
|
||||||
"__playlist-browser-open"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||||
"command": [
|
"command": ["__replay-subtitle"], // Command setting.
|
||||||
"__replay-subtitle"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Shift+KeyL", // Key setting.
|
"key": "Ctrl+Shift+KeyL", // Key setting.
|
||||||
"command": [
|
"command": ["__play-next-subtitle"], // Command setting.
|
||||||
"__play-next-subtitle"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyQ", // Key setting.
|
"key": "KeyQ", // Key setting.
|
||||||
"command": [
|
"command": ["quit"], // Command setting.
|
||||||
"quit"
|
|
||||||
] // Command setting.
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+KeyW", // Key setting.
|
"key": "Ctrl+KeyW", // Key setting.
|
||||||
"command": [
|
"command": ["quit"], // Command setting.
|
||||||
"quit"
|
},
|
||||||
] // Command setting.
|
|
||||||
}
|
|
||||||
], // Default and custom keybindings that are merged with built-in defaults.
|
], // Default and custom keybindings that are merged with built-in defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -332,7 +285,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
"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
|
"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.
|
}, // 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.
|
"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.
|
"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.
|
"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.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -352,7 +305,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"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.
|
}, // 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.
|
"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.
|
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||||
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color 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.
|
}, // 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
|
"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
|
"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.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#8bd5ca", // N4 setting.
|
"N4": "#8bd5ca", // N4 setting.
|
||||||
"N5": "#8aadf4" // N5 setting.
|
"N5": "#8aadf4", // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"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
|
"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
|
"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`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
"#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.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"css": {
|
"css": {
|
||||||
@@ -423,9 +370,9 @@
|
|||||||
"font-kerning": "normal", // Font kerning setting.
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
"text-rendering": "geometricPrecision", // Text rendering 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.
|
"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.
|
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||||
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||||
} // Secondary setting.
|
}, // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -450,8 +397,8 @@
|
|||||||
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
"--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-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-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.
|
"--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.
|
}, // 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.
|
}, // 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.
|
"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.
|
"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.
|
"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.
|
}, // 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
|
"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.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
"port": 8766, // Bind port 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.
|
}, // Proxy setting.
|
||||||
"tags": [
|
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"SubMiner"
|
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
"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.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||||
"model": "", // Optional model override for Anki AI translation/enrichment flows.
|
"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.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
"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
|
"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.
|
"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.
|
"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.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"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
|
"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.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"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
|
"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
|
"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
|
"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.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
"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.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"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.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
"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.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"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
|
"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
|
"deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -556,7 +501,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"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.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -565,10 +510,7 @@
|
|||||||
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
|
||||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -589,9 +531,9 @@
|
|||||||
"collapsibleSections": {
|
"collapsibleSections": {
|
||||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
"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
|
"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
|
"voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
|
||||||
} // Collapsible sections setting.
|
}, // Collapsible sections setting.
|
||||||
} // Character dictionary setting.
|
}, // Character dictionary setting.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -602,7 +544,7 @@
|
|||||||
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"yomitan": {
|
"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.
|
}, // 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
|
"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.
|
"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
|
"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.
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -636,27 +578,15 @@
|
|||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
"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.
|
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. 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
|
"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
|
"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.
|
"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
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
||||||
"mkv",
|
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
||||||
"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.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -668,7 +598,7 @@
|
|||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"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
|
"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.
|
"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.
|
}, // 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.
|
"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.
|
"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.
|
"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.
|
}, // Retention setting.
|
||||||
"lifetimeSummaries": {
|
"lifetimeSummaries": {
|
||||||
"global": true, // Maintain global lifetime stats rows. Values: true | false
|
"global": true, // Maintain global lifetime stats rows. Values: true | false
|
||||||
"anime": true, // Maintain per-anime 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
|
"media": true, // Maintain per-media lifetime stats rows. Values: true | false
|
||||||
} // Lifetime summaries setting.
|
}, // Lifetime summaries setting.
|
||||||
}, // Enable/disable immersion tracking.
|
}, // 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.
|
"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.
|
"serverPort": 6969, // Port for the stats HTTP server.
|
||||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
"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
|
"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.
|
}, // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection(
|
|||||||
fail('Selected Jellyfin item is not playable.');
|
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(
|
async function runAppJellyfinListCommand(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
args: Args,
|
args: Args,
|
||||||
@@ -384,14 +399,7 @@ async function runAppJellyfinCommand(
|
|||||||
appArgs: string[],
|
appArgs: string[],
|
||||||
label: string,
|
label: string,
|
||||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
||||||
const forwardedBase = [...appArgs];
|
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
||||||
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
|
|
||||||
if (serverOverride) {
|
|
||||||
forwardedBase.push('--jellyfin-server', serverOverride);
|
|
||||||
}
|
|
||||||
if (args.passwordStore) {
|
|
||||||
forwardedBase.push('--password-store', args.passwordStore);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readLogAppendedSince = (offset: number): string => {
|
const readLogAppendedSince = (offset: number): string => {
|
||||||
const logPath = getMpvLogPath();
|
const logPath = getMpvLogPath();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
parseEpisodePathFromDisplay,
|
parseEpisodePathFromDisplay,
|
||||||
buildRootSearchGroups,
|
buildRootSearchGroups,
|
||||||
classifyJellyfinChildSelection,
|
classifyJellyfinChildSelection,
|
||||||
|
buildForwardedJellyfinAppArgs,
|
||||||
} from './jellyfin.js';
|
} from './jellyfin.js';
|
||||||
|
|
||||||
type RunResult = {
|
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', () => {
|
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
||||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||||
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
||||||
|
|||||||
@@ -144,12 +144,21 @@ function M.create(ctx)
|
|||||||
and previous_media_identity ~= nil
|
and previous_media_identity ~= nil
|
||||||
and media_identity == previous_media_identity
|
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.pending_reload_media_identity = nil
|
||||||
state.current_media_identity = media_identity
|
state.current_media_identity = media_identity
|
||||||
|
if new_media_loaded then
|
||||||
|
state.suppress_ready_overlay_restore = false
|
||||||
|
end
|
||||||
|
|
||||||
if same_media_reload then
|
if same_media_reload then
|
||||||
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
|
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", {
|
process.run_control_command_async("show-visible-overlay", {
|
||||||
socket_path = opts.socket_path,
|
socket_path = opts.socket_path,
|
||||||
})
|
})
|
||||||
|
|||||||
+24
-12
@@ -31,6 +31,16 @@ function M.create(ctx)
|
|||||||
return options_helper.coerce_bool(raw_visible_overlay, false)
|
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||||
end
|
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 function resolve_pause_until_ready()
|
||||||
local raw_pause_until_ready = opts.auto_start_pause_until_ready
|
local raw_pause_until_ready = opts.auto_start_pause_until_ready
|
||||||
if raw_pause_until_ready == nil then
|
if raw_pause_until_ready == nil then
|
||||||
@@ -129,7 +139,7 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function release_auto_play_ready_gate(reason)
|
local function release_auto_play_ready_gate(reason)
|
||||||
if not state.auto_play_ready_gate_armed then
|
if not state.auto_play_ready_gate_armed then
|
||||||
return
|
return false
|
||||||
end
|
end
|
||||||
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
@@ -140,6 +150,7 @@ function M.create(ctx)
|
|||||||
else
|
else
|
||||||
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
|
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
|
||||||
end
|
end
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
local function arm_auto_play_ready_gate()
|
local function arm_auto_play_ready_gate()
|
||||||
@@ -179,9 +190,12 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function notify_auto_play_ready()
|
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
|
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
|
||||||
state.force_ready_overlay_restore = false
|
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
|
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -224,7 +238,7 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local should_show_visible = overrides.show_visible_overlay
|
local should_show_visible = overrides.show_visible_overlay
|
||||||
if should_show_visible == nil then
|
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
|
end
|
||||||
if should_show_visible then
|
if should_show_visible then
|
||||||
table.insert(args, "--show-visible-overlay")
|
table.insert(args, "--show-visible-overlay")
|
||||||
@@ -399,9 +413,6 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function start_overlay(overrides)
|
local function start_overlay(overrides)
|
||||||
overrides = overrides or {}
|
overrides = overrides or {}
|
||||||
if overrides.auto_start_trigger == true then
|
|
||||||
state.suppress_ready_overlay_restore = false
|
|
||||||
end
|
|
||||||
|
|
||||||
if not binary.ensure_binary_available() then
|
if not binary.ensure_binary_available() then
|
||||||
subminer_log("error", "binary", "SubMiner binary not found")
|
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
|
elseif not state.auto_play_ready_gate_armed then
|
||||||
disarm_auto_play_ready_gate()
|
disarm_auto_play_ready_gate()
|
||||||
end
|
end
|
||||||
local visibility_action = resolve_visible_overlay_startup()
|
local visibility_action = resolve_auto_start_visibility_action()
|
||||||
and "show-visible-overlay"
|
if visibility_action ~= nil then
|
||||||
or "hide-visible-overlay"
|
|
||||||
run_control_command_async(visibility_action, {
|
run_control_command_async(visibility_action, {
|
||||||
socket_path = socket_path,
|
socket_path = socket_path,
|
||||||
log_level = overrides.log_level,
|
log_level = overrides.log_level,
|
||||||
})
|
})
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
subminer_log("info", "process", "Overlay already running")
|
subminer_log("info", "process", "Overlay already running")
|
||||||
@@ -495,14 +506,14 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if overrides.auto_start_trigger == true then
|
if overrides.auto_start_trigger == true then
|
||||||
local visibility_action = resolve_visible_overlay_startup()
|
local visibility_action = resolve_auto_start_visibility_action()
|
||||||
and "show-visible-overlay"
|
if visibility_action ~= nil then
|
||||||
or "hide-visible-overlay"
|
|
||||||
run_control_command_async(visibility_action, {
|
run_control_command_async(visibility_action, {
|
||||||
socket_path = socket_path,
|
socket_path = socket_path,
|
||||||
log_level = overrides.log_level,
|
log_level = overrides.log_level,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -576,6 +587,7 @@ function M.create(ctx)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
state.suppress_ready_overlay_restore = true
|
state.suppress_ready_overlay_restore = true
|
||||||
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
|
|
||||||
run_control_command_async("toggle-visible-overlay", nil, function(ok)
|
run_control_command_async("toggle-visible-overlay", nil, function(ok)
|
||||||
if not ok then
|
if not ok then
|
||||||
|
|||||||
@@ -1396,7 +1396,7 @@ do
|
|||||||
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
|
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
|
||||||
)
|
)
|
||||||
assert_true(
|
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"
|
"duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
@@ -1471,6 +1471,33 @@ do
|
|||||||
)
|
)
|
||||||
end
|
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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -1531,6 +1558,10 @@ do
|
|||||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||||
"manual toggle-off before readiness should suppress ready-time visible overlay restore"
|
"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
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -1564,6 +1595,75 @@ do
|
|||||||
)
|
)
|
||||||
end
|
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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
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.enabled, false);
|
||||||
assert.equal(config.ai.apiKeyCommand, '');
|
assert.equal(config.ai.apiKeyCommand, '');
|
||||||
assert.equal(config.texthooker.openBrowser, false);
|
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();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
path.join(dir, 'config.jsonc'),
|
||||||
@@ -836,6 +839,7 @@ test('parses jellyfin remote control fields', () => {
|
|||||||
"remoteControlEnabled": true,
|
"remoteControlEnabled": true,
|
||||||
"remoteControlAutoConnect": true,
|
"remoteControlAutoConnect": true,
|
||||||
"autoAnnounce": true,
|
"autoAnnounce": true,
|
||||||
|
"clientName": "Custom Client",
|
||||||
"remoteControlDeviceName": "SubMiner"
|
"remoteControlDeviceName": "SubMiner"
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -850,7 +854,8 @@ test('parses jellyfin remote control fields', () => {
|
|||||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||||
assert.equal(config.jellyfin.autoAnnounce, 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', () => {
|
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, /"startupWarmups":/);
|
||||||
assert.match(output, /"updates":/);
|
assert.match(output, /"updates":/);
|
||||||
assert.match(output, /"youtube":/);
|
assert.match(output, /"youtube":/);
|
||||||
|
assert.doesNotMatch(output, /"deviceId":/);
|
||||||
|
assert.doesNotMatch(output, /"clientVersion":/);
|
||||||
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||||
assert.match(output, /"characterDictionary":\s*\{/);
|
assert.match(output, /"characterDictionary":\s*\{/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
|
|||||||
@@ -126,14 +126,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
recentServers: [],
|
recentServers: [],
|
||||||
username: '',
|
username: '',
|
||||||
deviceId: 'subminer',
|
|
||||||
clientName: 'SubMiner',
|
|
||||||
clientVersion: '0.1.0',
|
|
||||||
defaultLibraryId: '',
|
defaultLibraryId: '',
|
||||||
remoteControlEnabled: true,
|
remoteControlEnabled: true,
|
||||||
remoteControlAutoConnect: true,
|
remoteControlAutoConnect: true,
|
||||||
autoAnnounce: false,
|
autoAnnounce: false,
|
||||||
remoteControlDeviceName: 'SubMiner',
|
|
||||||
pullPictures: false,
|
pullPictures: false,
|
||||||
iconCacheDir: '/tmp/subminer-jellyfin-icons',
|
iconCacheDir: '/tmp/subminer-jellyfin-icons',
|
||||||
directPlayPreferred: true,
|
directPlayPreferred: true,
|
||||||
|
|||||||
@@ -520,26 +520,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.jellyfin.username,
|
defaultValue: defaultConfig.jellyfin.username,
|
||||||
description: 'Default Jellyfin username used during CLI login.',
|
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',
|
path: 'jellyfin.defaultLibraryId',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
@@ -565,12 +545,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
|
'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',
|
path: 'jellyfin.pullPictures',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -364,9 +364,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
const stringKeys = [
|
const stringKeys = [
|
||||||
'serverUrl',
|
'serverUrl',
|
||||||
'username',
|
'username',
|
||||||
'deviceId',
|
|
||||||
'clientName',
|
|
||||||
'clientVersion',
|
|
||||||
'defaultLibraryId',
|
'defaultLibraryId',
|
||||||
'iconCacheDir',
|
'iconCacheDir',
|
||||||
'transcodeVideoCodec',
|
'transcodeVideoCodec',
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ test('settings registry hides removed modal-only fields', () => {
|
|||||||
'shortcuts.multiCopyTimeoutMs',
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
'anilist.characterDictionary.profileScope',
|
'anilist.characterDictionary.profileScope',
|
||||||
'jellyfin.directPlayContainers',
|
'jellyfin.directPlayContainers',
|
||||||
'jellyfin.remoteControlDeviceName',
|
|
||||||
]) {
|
]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fields.some((candidate) => candidate.configPath === path),
|
fields.some((candidate) => candidate.configPath === path),
|
||||||
@@ -244,10 +243,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
|||||||
'controller.preferredGamepadLabel',
|
'controller.preferredGamepadLabel',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'youtubeSubgen.whisperBin',
|
'youtubeSubgen.whisperBin',
|
||||||
'jellyfin.clientVersion',
|
|
||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'jellyfin.deviceId',
|
|
||||||
'jellyfin.clientName',
|
|
||||||
'subtitleSidebar.toggleKey',
|
'subtitleSidebar.toggleKey',
|
||||||
'jellyfin.recentServers',
|
'jellyfin.recentServers',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'anilist.characterDictionary.profileScope',
|
'anilist.characterDictionary.profileScope',
|
||||||
'jellyfin.accessToken',
|
'jellyfin.accessToken',
|
||||||
'jellyfin.userId',
|
'jellyfin.userId',
|
||||||
'jellyfin.clientName',
|
|
||||||
'jellyfin.clientVersion',
|
|
||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'jellyfin.deviceId',
|
|
||||||
'jellyfin.directPlayContainers',
|
'jellyfin.directPlayContainers',
|
||||||
'jellyfin.remoteControlDeviceName',
|
|
||||||
'controller.buttonIndices',
|
'controller.buttonIndices',
|
||||||
'shortcuts.multiCopyTimeoutMs',
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
'subtitleSidebar.toggleKey',
|
'subtitleSidebar.toggleKey',
|
||||||
|
|||||||
@@ -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 () => {
|
test('service deduplicates identical updates and sends changed timeline', async () => {
|
||||||
const sent: DiscordActivityPayload[] = [];
|
const sent: DiscordActivityPayload[] = [];
|
||||||
const timers = new Map<number, () => void>();
|
const timers = new Map<number, () => void>();
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
|
|||||||
return parts[parts.length - 1] ?? '';
|
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 {
|
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
|
||||||
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
||||||
if (snapshot.paused) return 'Paused';
|
if (snapshot.paused) return 'Paused';
|
||||||
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
|
|||||||
): DiscordActivityPayload {
|
): DiscordActivityPayload {
|
||||||
const style = resolvePresenceStyle(config.presenceStyle);
|
const style = resolvePresenceStyle(config.presenceStyle);
|
||||||
const status = buildStatus(snapshot);
|
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 =
|
const details =
|
||||||
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||||
|
|||||||
+38
-46
@@ -403,6 +403,11 @@ import {
|
|||||||
launchWindowsMpv,
|
launchWindowsMpv,
|
||||||
} from './main/runtime/windows-mpv-launch';
|
} from './main/runtime/windows-mpv-launch';
|
||||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
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 {
|
import {
|
||||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||||
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
|
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
|
||||||
@@ -507,6 +512,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
|||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
|
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 { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||||
import {
|
import {
|
||||||
createElectronAppUpdater,
|
createElectronAppUpdater,
|
||||||
@@ -613,6 +619,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
|
|||||||
appDataDir: process.env.APPDATA,
|
appDataDir: process.env.APPDATA,
|
||||||
});
|
});
|
||||||
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
|
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_SETUP_RESPONSE_TYPE = 'token';
|
||||||
const ANILIST_DEFAULT_CLIENT_ID = '36084';
|
const ANILIST_DEFAULT_CLIENT_ID = '36084';
|
||||||
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
|
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
|
||||||
@@ -2825,7 +2840,9 @@ const {
|
|||||||
},
|
},
|
||||||
getJellyfinClientInfoMainDeps: {
|
getJellyfinClientInfoMainDeps: {
|
||||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||||
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin,
|
getHostName: () => os.hostname(),
|
||||||
|
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||||
|
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||||
},
|
},
|
||||||
waitForMpvConnectedMainDeps: {
|
waitForMpvConnectedMainDeps: {
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
@@ -2881,41 +2898,8 @@ const {
|
|||||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
},
|
},
|
||||||
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||||
cacheSubtitleTrack: async (track) => {
|
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
|
||||||
if (!track.deliveryUrl) {
|
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
|
||||||
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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
logDebug: (message, error) => {
|
logDebug: (message, error) => {
|
||||||
logger.debug(message, error);
|
logger.debug(message, error);
|
||||||
},
|
},
|
||||||
@@ -2958,6 +2942,9 @@ const {
|
|||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
showMpvOsd(text);
|
showMpvOsd(text);
|
||||||
},
|
},
|
||||||
|
updateCurrentMediaTitle: (title) => {
|
||||||
|
mediaRuntime.updateCurrentMediaTitle(title);
|
||||||
|
},
|
||||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
||||||
@@ -3022,11 +3009,13 @@ const {
|
|||||||
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
|
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
|
||||||
},
|
},
|
||||||
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
|
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
|
||||||
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
|
getHostName: () => os.hostname(),
|
||||||
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
|
defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
|
||||||
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
|
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||||
|
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message, details) => logger.warn(message, details),
|
logWarn: (message, details) => logger.warn(message, details),
|
||||||
|
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||||
},
|
},
|
||||||
stopJellyfinRemoteSessionMainDeps: {
|
stopJellyfinRemoteSessionMainDeps: {
|
||||||
getCurrentSession: () => appState.jellyfinRemoteSession,
|
getCurrentSession: () => appState.jellyfinRemoteSession,
|
||||||
@@ -3036,6 +3025,7 @@ const {
|
|||||||
clearActivePlayback: () => {
|
clearActivePlayback: () => {
|
||||||
activeJellyfinRemotePlayback = null;
|
activeJellyfinRemotePlayback = null;
|
||||||
},
|
},
|
||||||
|
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||||
},
|
},
|
||||||
runJellyfinCommandMainDeps: {
|
runJellyfinCommandMainDeps: {
|
||||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||||
@@ -3056,7 +3046,6 @@ const {
|
|||||||
clearStoredSession: () =>
|
clearStoredSession: () =>
|
||||||
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||||
patchJellyfinConfig: (session) => {
|
patchJellyfinConfig: (session) => {
|
||||||
const clientInfo = getJellyfinClientInfo();
|
|
||||||
const recentServers = mergeJellyfinRecentServers(
|
const recentServers = mergeJellyfinRecentServers(
|
||||||
session.serverUrl,
|
session.serverUrl,
|
||||||
getResolvedConfig().jellyfin.recentServers || [],
|
getResolvedConfig().jellyfin.recentServers || [],
|
||||||
@@ -3066,9 +3055,6 @@ const {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
serverUrl: session.serverUrl,
|
serverUrl: session.serverUrl,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
deviceId: clientInfo.deviceId,
|
|
||||||
clientName: clientInfo.clientName,
|
|
||||||
clientVersion: clientInfo.clientVersion,
|
|
||||||
recentServers,
|
recentServers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -4389,8 +4375,8 @@ const {
|
|||||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||||
}
|
|
||||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||||
|
}
|
||||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
@@ -6081,6 +6067,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
},
|
},
|
||||||
buildTrayMenuTemplateDeps: {
|
buildTrayMenuTemplateDeps: {
|
||||||
buildTrayMenuTemplateRuntime,
|
buildTrayMenuTemplateRuntime,
|
||||||
|
platform: process.platform,
|
||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||||
@@ -6096,8 +6083,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
isJellyfinConfigured: () =>
|
isJellyfinConfigured: () =>
|
||||||
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||||
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
|
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
|
||||||
toggleJellyfinDiscovery: () =>
|
toggleJellyfinDiscovery: (checked: boolean) =>
|
||||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
|
||||||
|
desiredActive: checked,
|
||||||
|
}),
|
||||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
void getUpdateService().checkForUpdates({ source: 'manual' });
|
void getUpdateService().checkForUpdates({ source: 'manual' });
|
||||||
@@ -6329,6 +6318,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
|||||||
function setVisibleOverlayVisible(visible: boolean): void {
|
function setVisibleOverlayVisible(visible: boolean): void {
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
ensureOverlayWindowsReadyForVisibilityActions();
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
|
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
}
|
}
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -6340,6 +6330,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
|||||||
|
|
||||||
function toggleVisibleOverlay(): void {
|
function toggleVisibleOverlay(): void {
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
ensureOverlayWindowsReadyForVisibilityActions();
|
||||||
|
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||||
if (overlayManager.getVisibleOverlayVisible()) {
|
if (overlayManager.getVisibleOverlayVisible()) {
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
} else {
|
} else {
|
||||||
@@ -6350,6 +6341,7 @@ function toggleVisibleOverlay(): void {
|
|||||||
}
|
}
|
||||||
function setOverlayVisible(visible: boolean): void {
|
function setOverlayVisible(visible: boolean): void {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
|
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
}
|
}
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
|||||||
@@ -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*\{(?<body>[\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 \{(?<body>[\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', () => {
|
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||||
|
|||||||
@@ -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'));
|
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', () => {
|
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let windowCount = 1;
|
let windowCount = 1;
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.clearFirstRunSetupWindow();
|
deps.clearFirstRunSetupWindow();
|
||||||
deps.destroyYomitanSettingsWindow();
|
deps.destroyYomitanSettingsWindow();
|
||||||
deps.clearYomitanSettingsWindow();
|
deps.clearYomitanSettingsWindow();
|
||||||
|
try {
|
||||||
deps.stopJellyfinRemoteSession();
|
deps.stopJellyfinRemoteSession();
|
||||||
|
} finally {
|
||||||
deps.cleanupJellyfinSubtitleCache();
|
deps.cleanupJellyfinSubtitleCache();
|
||||||
|
}
|
||||||
deps.stopDiscordPresenceService();
|
deps.stopDiscordPresenceService();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
(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 () => {
|
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<Array<string | boolean>> = [];
|
||||||
|
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<string | boolean> }) => {
|
||||||
|
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<Array<string | boolean>> = [];
|
||||||
|
|
||||||
|
const gate = createAutoplayReadyGate({
|
||||||
|
isAppOwnedFlowInFlight: () => false,
|
||||||
|
getCurrentMediaPath: () => '/media/video.mkv',
|
||||||
|
getCurrentVideoPath: () => null,
|
||||||
|
getPlaybackPaused: () => true,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async () => true,
|
||||||
|
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||||
|
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 () => {
|
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
|
||||||
const commands: Array<Array<string | boolean>> = [];
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
let targetReady = false;
|
let targetReady = false;
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
const getSignalMediaPath = (): string =>
|
const getSignalMediaPath = (): string =>
|
||||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||||
|
|
||||||
|
const markCurrentMediaAutoplayReady = (): void => {
|
||||||
|
pendingAutoplayReadySignal = null;
|
||||||
|
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
||||||
|
autoPlayReadySignalGeneration += 1;
|
||||||
|
};
|
||||||
|
|
||||||
const maybeSignalPluginAutoplayReady = (
|
const maybeSignalPluginAutoplayReady = (
|
||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
options?: { forceWhilePaused?: boolean },
|
options?: { forceWhilePaused?: boolean },
|
||||||
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
forceWhilePaused: options?.forceWhilePaused === true,
|
forceWhilePaused: options?.forceWhilePaused === true,
|
||||||
retryDelayMs: releaseRetryDelayMs,
|
retryDelayMs: releaseRetryDelayMs,
|
||||||
});
|
});
|
||||||
|
let releaseUnpauseSent = false;
|
||||||
|
|
||||||
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
|
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
return;
|
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);
|
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||||
if (!shouldUnpause) {
|
if (!shouldUnpause) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||||
|
releaseUnpauseSent = true;
|
||||||
if (attempt < maxReleaseAttempts) {
|
if (attempt < maxReleaseAttempts) {
|
||||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||||
}
|
}
|
||||||
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
flushPendingAutoplayReadySignal,
|
flushPendingAutoplayReadySignal,
|
||||||
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
||||||
invalidatePendingAutoplayReadyFallbacks,
|
invalidatePendingAutoplayReadyFallbacks,
|
||||||
|
markCurrentMediaAutoplayReady,
|
||||||
maybeSignalPluginAutoplayReady,
|
maybeSignalPluginAutoplayReady,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function composeJellyfinRemoteHandlers(
|
|||||||
getConfiguredSession: options.getConfiguredSession,
|
getConfiguredSession: options.getConfiguredSession,
|
||||||
getClientInfo: options.getClientInfo,
|
getClientInfo: options.getClientInfo,
|
||||||
getJellyfinConfig: options.getJellyfinConfig,
|
getJellyfinConfig: options.getJellyfinConfig,
|
||||||
|
getActivePlayback: options.getActivePlayback,
|
||||||
playJellyfinItem: options.playJellyfinItem,
|
playJellyfinItem: options.playJellyfinItem,
|
||||||
logWarn: options.logWarn,
|
logWarn: options.logWarn,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
},
|
},
|
||||||
getJellyfinClientInfoMainDeps: {
|
getJellyfinClientInfoMainDeps: {
|
||||||
getResolvedJellyfinConfig: () => ({}) as never,
|
getResolvedJellyfinConfig: () => ({}) as never,
|
||||||
getDefaultJellyfinConfig: () => ({
|
getHostName: () => 'workstation',
|
||||||
clientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
clientVersion: 'test',
|
defaultClientVersion: 'test',
|
||||||
deviceId: 'dev',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
waitForMpvConnectedMainDeps: {
|
waitForMpvConnectedMainDeps: {
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
@@ -140,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
defaultDeviceId: 'dev',
|
defaultDeviceId: 'dev',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: 'test',
|
defaultClientVersion: 'test',
|
||||||
|
getHostName: () => 'workstation',
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
|||||||
>;
|
>;
|
||||||
startJellyfinRemoteSessionMainDeps: Omit<
|
startJellyfinRemoteSessionMainDeps: Omit<
|
||||||
StartRemoteSessionMainDeps,
|
StartRemoteSessionMainDeps,
|
||||||
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
|
| 'getJellyfinConfig'
|
||||||
|
| 'getClientInfo'
|
||||||
|
| 'handlePlay'
|
||||||
|
| 'handlePlaystate'
|
||||||
|
| 'handleGeneralCommand'
|
||||||
>;
|
>;
|
||||||
stopJellyfinRemoteSessionMainDeps: Parameters<
|
stopJellyfinRemoteSessionMainDeps: Parameters<
|
||||||
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
||||||
@@ -236,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
|
|||||||
createBuildStartJellyfinRemoteSessionMainDepsHandler({
|
createBuildStartJellyfinRemoteSessionMainDepsHandler({
|
||||||
...options.startJellyfinRemoteSessionMainDeps,
|
...options.startJellyfinRemoteSessionMainDeps,
|
||||||
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||||
|
getClientInfo: () => getJellyfinClientInfo(),
|
||||||
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
|
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
|
||||||
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
|
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
|
||||||
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
|
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
|
|||||||
export * from '../jellyfin-client-info-main-deps';
|
export * from '../jellyfin-client-info-main-deps';
|
||||||
export * from '../jellyfin-command-dispatch';
|
export * from '../jellyfin-command-dispatch';
|
||||||
export * from '../jellyfin-command-dispatch-main-deps';
|
export * from '../jellyfin-command-dispatch-main-deps';
|
||||||
|
export * from '../jellyfin-device-identity';
|
||||||
export * from '../jellyfin-playback-launch';
|
export * from '../jellyfin-playback-launch';
|
||||||
export * from '../jellyfin-playback-launch-main-deps';
|
export * from '../jellyfin-playback-launch-main-deps';
|
||||||
export * from '../jellyfin-remote-commands';
|
export * from '../jellyfin-remote-commands';
|
||||||
|
|||||||
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
serverUrl: 'http://localhost',
|
serverUrl: 'http://localhost',
|
||||||
username: 'user',
|
username: 'user',
|
||||||
deviceId: 'd1',
|
|
||||||
clientName: 'SubMiner',
|
|
||||||
clientVersion: '1.0',
|
|
||||||
recentServers: ['http://localhost'],
|
recentServers: ['http://localhost'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
|
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 patchPayload: unknown = null;
|
||||||
let storedSession: unknown = null;
|
let storedSession: unknown = null;
|
||||||
|
|
||||||
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
serverUrl: 'http://localhost:8096',
|
serverUrl: 'http://localhost:8096',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
deviceId: 'device-1',
|
|
||||||
clientName: 'SubMiner',
|
|
||||||
clientVersion: '1.0',
|
|
||||||
recentServers: [
|
recentServers: [
|
||||||
'http://localhost:8096',
|
'http://localhost:8096',
|
||||||
'http://old.example: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 () => {
|
test('jellyfin auth handler no-ops when no auth command', async () => {
|
||||||
const handleAuth = createHandleJellyfinAuthCommands({
|
const handleAuth = createHandleJellyfinAuthCommands({
|
||||||
patchRawConfig: () => {},
|
patchRawConfig: () => {},
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
deviceId: string;
|
|
||||||
clientName: string;
|
|
||||||
clientVersion: string;
|
|
||||||
recentServers: string[];
|
recentServers: string[];
|
||||||
}>;
|
}>;
|
||||||
}) => void;
|
}) => void;
|
||||||
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
serverUrl: deps.session.serverUrl,
|
serverUrl: deps.session.serverUrl,
|
||||||
username: deps.session.username,
|
username: deps.session.username,
|
||||||
deviceId: deps.clientInfo.deviceId,
|
|
||||||
clientName: deps.clientInfo.clientName,
|
|
||||||
clientVersion: deps.clientInfo.clientVersion,
|
|
||||||
recentServers: mergeJellyfinRecentServers(
|
recentServers: mergeJellyfinRecentServers(
|
||||||
deps.session.serverUrl,
|
deps.session.serverUrl,
|
||||||
deps.existingRecentServers || [],
|
deps.existingRecentServers || [],
|
||||||
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
deviceId: string;
|
|
||||||
clientName: string;
|
|
||||||
clientVersion: string;
|
|
||||||
}>;
|
}>;
|
||||||
}) => void;
|
}) => void;
|
||||||
authenticateWithPassword: (
|
authenticateWithPassword: (
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
|
|||||||
|
|
||||||
test('get jellyfin client info main deps builder maps callbacks', () => {
|
test('get jellyfin client info main deps builder maps callbacks', () => {
|
||||||
const configured = { clientName: 'Configured' };
|
const configured = { clientName: 'Configured' };
|
||||||
const defaults = { clientName: 'Default' };
|
|
||||||
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
|
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
|
||||||
getResolvedJellyfinConfig: () => configured as never,
|
getResolvedJellyfinConfig: () => configured as never,
|
||||||
getDefaultJellyfinConfig: () => defaults as never,
|
getHostName: () => 'workstation',
|
||||||
|
defaultClientName: 'SubMiner',
|
||||||
|
defaultClientVersion: '1.0.0',
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.getResolvedJellyfinConfig(), configured);
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
|
|||||||
) {
|
) {
|
||||||
return (): GetJellyfinClientInfoMainDeps => ({
|
return (): GetJellyfinClientInfoMainDeps => ({
|
||||||
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
||||||
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(),
|
getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
|
||||||
|
defaultClientName: deps.defaultClientName,
|
||||||
|
defaultClientVersion: deps.defaultClientVersion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
test('jellyfin client info resolves defaults when fields are missing', () => {
|
||||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||||
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never,
|
getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
|
||||||
getDefaultJellyfinConfig: () =>
|
getHostName: () => 'workstation',
|
||||||
({
|
defaultClientName: 'SubMiner',
|
||||||
clientName: 'SubMiner',
|
defaultClientVersion: '1.0.0',
|
||||||
clientVersion: '1.0.0',
|
|
||||||
deviceId: 'default-device',
|
|
||||||
}) as never,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(getClientInfo(), {
|
assert.deepEqual(getClientInfo(), {
|
||||||
clientName: 'SubMiner',
|
clientName: 'SubMiner',
|
||||||
clientVersion: '1.0.0',
|
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({
|
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||||
getResolvedJellyfinConfig: () =>
|
getResolvedJellyfinConfig: () =>
|
||||||
({
|
({
|
||||||
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
|
|||||||
clientVersion: '2.3.4',
|
clientVersion: '2.3.4',
|
||||||
deviceId: 'custom-device',
|
deviceId: 'custom-device',
|
||||||
}) as never,
|
}) as never,
|
||||||
getDefaultJellyfinConfig: () =>
|
getHostName: () => 'Kyle-PC',
|
||||||
({
|
defaultClientName: 'SubMiner',
|
||||||
clientName: 'SubMiner',
|
defaultClientVersion: '1.0.0',
|
||||||
clientVersion: '1.0.0',
|
|
||||||
deviceId: 'default-device',
|
|
||||||
}) as never,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(getClientInfo(), {
|
assert.deepEqual(getClientInfo(), {
|
||||||
clientName: 'Custom',
|
clientName: 'SubMiner',
|
||||||
clientVersion: '2.3.4',
|
clientVersion: '1.0.0',
|
||||||
deviceId: 'custom-device',
|
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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
|
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
|
||||||
import type { ResolvedConfig } from '../../types';
|
import type { ResolvedConfig } from '../../types';
|
||||||
|
import {
|
||||||
|
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||||
|
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||||
|
createHostDerivedJellyfinDeviceId,
|
||||||
|
} from './jellyfin-device-identity';
|
||||||
|
|
||||||
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
|
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
|
||||||
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
|
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
|
||||||
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createGetJellyfinClientInfoHandler(deps: {
|
export function createGetJellyfinClientInfoHandler(deps: {
|
||||||
getResolvedJellyfinConfig: () => Partial<
|
getResolvedJellyfinConfig: () => unknown;
|
||||||
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
|
getHostName?: () => string;
|
||||||
>;
|
defaultClientName?: string;
|
||||||
getDefaultJellyfinConfig: () => Partial<
|
defaultClientVersion?: string;
|
||||||
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
|
|
||||||
>;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
config = deps.getResolvedJellyfinConfig(),
|
_config = deps.getResolvedJellyfinConfig(),
|
||||||
): {
|
): {
|
||||||
clientName: string;
|
clientName: string;
|
||||||
clientVersion: string;
|
clientVersion: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
} => {
|
} => {
|
||||||
const defaults = deps.getDefaultJellyfinConfig();
|
|
||||||
return {
|
return {
|
||||||
clientName: config.clientName || defaults.clientName || '',
|
clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||||
clientVersion: config.clientVersion || defaults.clientVersion || '',
|
clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||||
deviceId: config.deviceId || defaults.deviceId || '',
|
deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -23,5 +23,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
|||||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
|
||||||
|
? (title) => deps.updateCurrentMediaTitle!(title)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,11 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
['set_property', 'sid', 'no'],
|
['set_property', 'sid', 'no'],
|
||||||
['seek', 1.2, 'absolute+exact'],
|
['seek', 1.2, 'absolute+exact'],
|
||||||
]);
|
]);
|
||||||
assert.equal(scheduled.length, 1);
|
assert.equal(scheduled.length, 0);
|
||||||
assert.equal(scheduled[0]?.delay, 500);
|
assert.equal(
|
||||||
scheduled[0]?.callback();
|
commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
|
||||||
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
assert.ok(calls.includes('defaults'));
|
assert.ok(calls.includes('defaults'));
|
||||||
assert.ok(calls.includes('visible-overlay'));
|
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 () => {
|
test('playback handler applies start override to stream url for remote resume', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const handler = createPlayJellyfinItemInMpvHandler({
|
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.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
|
||||||
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
|
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<Array<string | number>> = [];
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
}) => void;
|
}) => void;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
|
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
|
||||||
|
updateCurrentMediaTitle?: (title: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (params: {
|
return async (params: {
|
||||||
session: JellyfinAuthSession;
|
session: JellyfinAuthSession;
|
||||||
@@ -106,6 +107,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||||
|
deps.updateCurrentMediaTitle?.(plan.title);
|
||||||
|
try {
|
||||||
deps.recordJellyfinPlaybackMetadata?.({
|
deps.recordJellyfinPlaybackMetadata?.({
|
||||||
mediaPath: playbackUrl,
|
mediaPath: playbackUrl,
|
||||||
displayTitle: plan.title,
|
displayTitle: plan.title,
|
||||||
@@ -115,15 +118,15 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
episodeNumber: plan.episodeNumber,
|
episodeNumber: plan.episodeNumber,
|
||||||
itemId: params.itemId,
|
itemId: params.itemId,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort stats metadata must not block playback startup.
|
||||||
|
}
|
||||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||||
if (params.setQuitOnDisconnectArm !== false) {
|
if (params.setQuitOnDisconnectArm !== false) {
|
||||||
deps.armQuitOnDisconnect();
|
deps.armQuitOnDisconnect();
|
||||||
}
|
}
|
||||||
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
deps.schedule(() => {
|
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
const startTimeTicks =
|
const startTimeTicks =
|
||||||
typeof params.startTimeTicksOverride === 'number'
|
typeof params.startTimeTicksOverride === 'number'
|
||||||
|
|||||||
@@ -101,6 +101,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
|
|||||||
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
|
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 () => {
|
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
|
||||||
const mpvClient = {};
|
const mpvClient = {};
|
||||||
const commands: Array<(string | number)[]> = [];
|
const commands: Array<(string | number)[]> = [];
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
|||||||
getConfiguredSession: () => JellyfinSession | null;
|
getConfiguredSession: () => JellyfinSession | null;
|
||||||
getClientInfo: () => JellyfinClientInfo;
|
getClientInfo: () => JellyfinClientInfo;
|
||||||
getJellyfinConfig: () => unknown;
|
getJellyfinConfig: () => unknown;
|
||||||
|
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
|
||||||
playJellyfinItem: (params: {
|
playJellyfinItem: (params: {
|
||||||
session: JellyfinSession;
|
session: JellyfinSession;
|
||||||
clientInfo: JellyfinClientInfo;
|
clientInfo: JellyfinClientInfo;
|
||||||
@@ -79,6 +80,9 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
|||||||
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
|
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (deps.getActivePlayback?.()?.itemId === itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await deps.playJellyfinItem({
|
await deps.playJellyfinItem({
|
||||||
session,
|
session,
|
||||||
clientInfo,
|
clientInfo,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
|
|||||||
getConfiguredSession: () => deps.getConfiguredSession(),
|
getConfiguredSession: () => deps.getConfiguredSession(),
|
||||||
getClientInfo: () => deps.getClientInfo(),
|
getClientInfo: () => deps.getClientInfo(),
|
||||||
getJellyfinConfig: () => deps.getJellyfinConfig(),
|
getJellyfinConfig: () => deps.getJellyfinConfig(),
|
||||||
|
...(deps.getActivePlayback
|
||||||
|
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
|
||||||
|
: {}),
|
||||||
playJellyfinItem: (params) => deps.playJellyfinItem(params),
|
playJellyfinItem: (params) => deps.playJellyfinItem(params),
|
||||||
logWarn: (message: string) => deps.logWarn(message),
|
logWarn: (message: string) => deps.logWarn(message),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,38 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
|||||||
assert.equal(lastProgressAtMs, 5000);
|
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 () => {
|
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
|
||||||
let called = false;
|
let called = false;
|
||||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
|
|||||||
return Math.max(0, Math.floor(seconds * ticksPerSecond));
|
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 = {
|
export type JellyfinRemoteProgressReporterDeps = {
|
||||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||||
clearActivePlayback: () => void;
|
clearActivePlayback: () => void;
|
||||||
@@ -64,7 +77,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
|||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
||||||
isPaused: paused === true,
|
isPaused: isMpvPauseEnabled(paused),
|
||||||
playMethod: playback.playMethod,
|
playMethod: playback.playMethod,
|
||||||
audioStreamIndex: playback.audioStreamIndex,
|
audioStreamIndex: playback.audioStreamIndex,
|
||||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
|
|||||||
serverUrl: 'http://localhost',
|
serverUrl: 'http://localhost',
|
||||||
accessToken: 'token',
|
accessToken: 'token',
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
deviceId: '',
|
|
||||||
clientName: '',
|
|
||||||
clientVersion: '',
|
|
||||||
remoteControlDeviceName: '',
|
|
||||||
autoAnnounce: false,
|
autoAnnounce: false,
|
||||||
...(overrides || {}),
|
...(overrides || {}),
|
||||||
} as never;
|
} as never;
|
||||||
@@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => {
|
|||||||
defaultDeviceId: 'default-device',
|
defaultDeviceId: 'default-device',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: '1.0',
|
defaultClientVersion: '1.0',
|
||||||
|
getClientInfo: () => ({
|
||||||
|
deviceId: 'workstation',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
}),
|
||||||
|
getHostName: () => 'workstation',
|
||||||
handlePlay: async () => {},
|
handlePlay: async () => {},
|
||||||
handlePlaystate: async () => {},
|
handlePlaystate: async () => {},
|
||||||
handleGeneralCommand: async () => {},
|
handleGeneralCommand: async () => {},
|
||||||
@@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => {
|
|||||||
defaultDeviceId: 'default-device',
|
defaultDeviceId: 'default-device',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: '1.0',
|
defaultClientVersion: '1.0',
|
||||||
|
getClientInfo: () => ({
|
||||||
|
deviceId: 'workstation',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
}),
|
||||||
|
getHostName: () => 'workstation',
|
||||||
handlePlay: async () => {},
|
handlePlay: async () => {},
|
||||||
handlePlaystate: async () => {},
|
handlePlaystate: async () => {},
|
||||||
handleGeneralCommand: async () => {},
|
handleGeneralCommand: async () => {},
|
||||||
@@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a
|
|||||||
defaultDeviceId: 'default-device',
|
defaultDeviceId: 'default-device',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: '1.0',
|
defaultClientVersion: '1.0',
|
||||||
|
getClientInfo: () => ({
|
||||||
|
deviceId: 'workstation',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
}),
|
||||||
|
getHostName: () => 'workstation',
|
||||||
handlePlay: async () => {},
|
handlePlay: async () => {},
|
||||||
handlePlaystate: async () => {},
|
handlePlaystate: async () => {},
|
||||||
handleGeneralCommand: async () => {},
|
handleGeneralCommand: async () => {},
|
||||||
@@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
let started = false;
|
let started = false;
|
||||||
const infos: string[] = [];
|
const infos: string[] = [];
|
||||||
|
let stateChanges = 0;
|
||||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||||
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
|
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
|
||||||
getCurrentSession: () => null,
|
getCurrentSession: () => null,
|
||||||
@@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => {
|
|||||||
storedSession = session as never;
|
storedSession = session as never;
|
||||||
},
|
},
|
||||||
createRemoteSessionService: (options) => {
|
createRemoteSessionService: (options) => {
|
||||||
assert.equal(options.deviceName, 'Desk');
|
assert.equal(options.deviceName, 'workstation');
|
||||||
return {
|
return {
|
||||||
start: () => {
|
start: () => {
|
||||||
started = true;
|
started = true;
|
||||||
@@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => {
|
|||||||
defaultDeviceId: 'default-device',
|
defaultDeviceId: 'default-device',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: '1.0',
|
defaultClientVersion: '1.0',
|
||||||
|
getClientInfo: () => ({
|
||||||
|
deviceId: 'workstation',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
}),
|
||||||
|
getHostName: () => 'workstation',
|
||||||
handlePlay: async () => {},
|
handlePlay: async () => {},
|
||||||
handlePlaystate: async () => {},
|
handlePlaystate: async () => {},
|
||||||
handleGeneralCommand: async () => {},
|
handleGeneralCommand: async () => {},
|
||||||
logInfo: (message) => infos.push(message),
|
logInfo: (message) => infos.push(message),
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
|
onSessionStateChanged: () => {
|
||||||
|
stateChanges += 1;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await startRemote();
|
await startRemote();
|
||||||
|
|
||||||
assert.equal(started, true);
|
assert.equal(started, true);
|
||||||
assert.ok(storedSession);
|
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 () => {
|
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',
|
defaultDeviceId: 'default-device',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: '1.0',
|
defaultClientVersion: '1.0',
|
||||||
|
getClientInfo: () => ({
|
||||||
|
deviceId: 'workstation',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
}),
|
||||||
|
getHostName: () => 'workstation',
|
||||||
handlePlay: async () => {},
|
handlePlay: async () => {},
|
||||||
handlePlaystate: async () => {},
|
handlePlaystate: async () => {},
|
||||||
handleGeneralCommand: 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', () => {
|
test('stop handler stops active session and clears playback', () => {
|
||||||
let stopCalls = 0;
|
let stopCalls = 0;
|
||||||
let clearCalls = 0;
|
let clearCalls = 0;
|
||||||
|
let stateChanges = 0;
|
||||||
let currentSession: { stop: () => void } | null = {
|
let currentSession: { stop: () => void } | null = {
|
||||||
stop: () => {
|
stop: () => {
|
||||||
stopCalls += 1;
|
stopCalls += 1;
|
||||||
@@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => {
|
|||||||
clearActivePlayback: () => {
|
clearActivePlayback: () => {
|
||||||
clearCalls += 1;
|
clearCalls += 1;
|
||||||
},
|
},
|
||||||
|
onSessionStateChanged: () => {
|
||||||
|
stateChanges += 1;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
stopRemote();
|
stopRemote();
|
||||||
assert.equal(stopCalls, 1);
|
assert.equal(stopCalls, 1);
|
||||||
assert.equal(clearCalls, 1);
|
assert.equal(clearCalls, 1);
|
||||||
assert.equal(currentSession, null);
|
assert.equal(currentSession, null);
|
||||||
|
assert.equal(stateChanges, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity';
|
||||||
|
|
||||||
type JellyfinRemoteConfig = {
|
type JellyfinRemoteConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
remoteControlEnabled: boolean;
|
remoteControlEnabled: boolean;
|
||||||
@@ -5,11 +7,13 @@ type JellyfinRemoteConfig = {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
autoAnnounce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JellyfinClientInfo = {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
clientVersion: string;
|
clientVersion: string;
|
||||||
remoteControlDeviceName: string;
|
|
||||||
autoAnnounce: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type JellyfinRemoteService = {
|
type JellyfinRemoteService = {
|
||||||
@@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
getCurrentSession: () => JellyfinRemoteService | null;
|
getCurrentSession: () => JellyfinRemoteService | null;
|
||||||
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
||||||
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
|
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
|
||||||
|
getClientInfo: () => JellyfinClientInfo;
|
||||||
|
getHostName: () => string;
|
||||||
defaultDeviceId: string;
|
defaultDeviceId: string;
|
||||||
defaultClientName: string;
|
defaultClientName: string;
|
||||||
defaultClientVersion: string;
|
defaultClientVersion: string;
|
||||||
@@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
|
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string, details?: unknown) => void;
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
onSessionStateChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return async (options?: { explicit?: boolean }): Promise<void> => {
|
return async (options?: { explicit?: boolean }): Promise<void> => {
|
||||||
const jellyfinConfig = deps.getJellyfinConfig();
|
const jellyfinConfig = deps.getJellyfinConfig();
|
||||||
@@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
|
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
|
||||||
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) 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();
|
const existing = deps.getCurrentSession();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.stop();
|
existing.stop();
|
||||||
@@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
const service = deps.createRemoteSessionService({
|
const service = deps.createRemoteSessionService({
|
||||||
serverUrl: jellyfinConfig.serverUrl,
|
serverUrl: jellyfinConfig.serverUrl,
|
||||||
accessToken: jellyfinConfig.accessToken,
|
accessToken: jellyfinConfig.accessToken,
|
||||||
deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId,
|
deviceId: clientInfo.deviceId || deps.defaultDeviceId,
|
||||||
clientName: jellyfinConfig.clientName || deps.defaultClientName,
|
clientName,
|
||||||
clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion,
|
clientVersion,
|
||||||
deviceName:
|
deviceName,
|
||||||
jellyfinConfig.remoteControlDeviceName ||
|
|
||||||
jellyfinConfig.clientName ||
|
|
||||||
deps.defaultClientName,
|
|
||||||
capabilities: {
|
capabilities: {
|
||||||
PlayableMediaTypes: 'Video,Audio',
|
PlayableMediaTypes: 'Video,Audio',
|
||||||
SupportedCommands:
|
SupportedCommands:
|
||||||
@@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
|
|
||||||
service.start();
|
service.start();
|
||||||
deps.setCurrentSession(service);
|
deps.setCurrentSession(service);
|
||||||
deps.logInfo(
|
deps.onSessionStateChanged?.();
|
||||||
`Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`,
|
deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
|
|||||||
getCurrentSession: () => JellyfinRemoteService | null;
|
getCurrentSession: () => JellyfinRemoteService | null;
|
||||||
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
||||||
clearActivePlayback: () => void;
|
clearActivePlayback: () => void;
|
||||||
|
onSessionStateChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): void => {
|
return (): void => {
|
||||||
const session = deps.getCurrentSession();
|
const session = deps.getCurrentSession();
|
||||||
@@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
|
|||||||
session.stop();
|
session.stop();
|
||||||
deps.setCurrentSession(null);
|
deps.setCurrentSession(null);
|
||||||
deps.clearActivePlayback();
|
deps.clearActivePlayback();
|
||||||
|
deps.onSessionStateChanged?.();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
|
|||||||
getCurrentSession: () => null,
|
getCurrentSession: () => null,
|
||||||
setCurrentSession: () => calls.push('set-session'),
|
setCurrentSession: () => calls.push('set-session'),
|
||||||
createRemoteSessionService: () => session as never,
|
createRemoteSessionService: () => session as never,
|
||||||
|
getClientInfo: () =>
|
||||||
|
({
|
||||||
|
deviceId: 'workstation',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
}) as never,
|
||||||
|
getHostName: () => 'workstation',
|
||||||
defaultDeviceId: 'device',
|
defaultDeviceId: 'device',
|
||||||
defaultClientName: 'SubMiner',
|
defaultClientName: 'SubMiner',
|
||||||
defaultClientVersion: '1.0',
|
defaultClientVersion: '1.0',
|
||||||
@@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
|
|||||||
},
|
},
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
onSessionStateChanged: () => calls.push('state-changed'),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
|
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
|
||||||
assert.equal(deps.defaultDeviceId, 'device');
|
assert.equal(deps.defaultDeviceId, 'device');
|
||||||
assert.equal(deps.defaultClientName, 'SubMiner');
|
assert.equal(deps.defaultClientName, 'SubMiner');
|
||||||
assert.equal(deps.defaultClientVersion, '1.0');
|
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);
|
assert.equal(deps.createRemoteSessionService({} as never), session);
|
||||||
await deps.handlePlay({});
|
await deps.handlePlay({});
|
||||||
await deps.handlePlaystate({});
|
await deps.handlePlaystate({});
|
||||||
await deps.handleGeneralCommand({});
|
await deps.handleGeneralCommand({});
|
||||||
deps.logInfo('connected');
|
deps.logInfo('connected');
|
||||||
deps.logWarn('missing');
|
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', () => {
|
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,
|
getCurrentSession: () => session as never,
|
||||||
setCurrentSession: () => calls.push('set-null'),
|
setCurrentSession: () => calls.push('set-null'),
|
||||||
clearActivePlayback: () => calls.push('clear'),
|
clearActivePlayback: () => calls.push('clear'),
|
||||||
|
onSessionStateChanged: () => calls.push('state-changed'),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.getCurrentSession(), session);
|
assert.equal(deps.getCurrentSession(), session);
|
||||||
deps.setCurrentSession(null);
|
deps.setCurrentSession(null);
|
||||||
deps.clearActivePlayback();
|
deps.clearActivePlayback();
|
||||||
assert.deepEqual(calls, ['set-null', 'clear']);
|
deps.onSessionStateChanged?.();
|
||||||
|
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
|
|||||||
getCurrentSession: () => deps.getCurrentSession(),
|
getCurrentSession: () => deps.getCurrentSession(),
|
||||||
setCurrentSession: (session) => deps.setCurrentSession(session),
|
setCurrentSession: (session) => deps.setCurrentSession(session),
|
||||||
createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
|
createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
|
||||||
|
getClientInfo: () => deps.getClientInfo(),
|
||||||
|
getHostName: () => deps.getHostName(),
|
||||||
defaultDeviceId: deps.defaultDeviceId,
|
defaultDeviceId: deps.defaultDeviceId,
|
||||||
defaultClientName: deps.defaultClientName,
|
defaultClientName: deps.defaultClientName,
|
||||||
defaultClientVersion: deps.defaultClientVersion,
|
defaultClientVersion: deps.defaultClientVersion,
|
||||||
@@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
|
|||||||
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
|
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
||||||
|
onSessionStateChanged: deps.onSessionStateChanged,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler(
|
|||||||
getCurrentSession: () => deps.getCurrentSession(),
|
getCurrentSession: () => deps.getCurrentSession(),
|
||||||
setCurrentSession: (session) => deps.setCurrentSession(session),
|
setCurrentSession: (session) => deps.setCurrentSession(session),
|
||||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||||
|
onSessionStateChanged: deps.onSessionStateChanged,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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<ArrayBuffer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JellyfinSubtitleCacheIoDeps = {
|
||||||
|
tmpDir: () => string;
|
||||||
|
makeTempDir: (prefix: string) => Promise<string>;
|
||||||
|
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||||
|
removeDir: (dir: string, options: { recursive: true; force: true }) => void;
|
||||||
|
fetch: (url: string) => Promise<FetchResponseLike>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<JellyfinSubtitleCacheEntry> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -61,8 +61,22 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
|||||||
],
|
],
|
||||||
getMpvClient: () => ({
|
getMpvClient: () => ({
|
||||||
requestProperty: async () => [
|
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),
|
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' });
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
|
||||||
assert.deepEqual(commands, [
|
assert.deepEqual(commands, [
|
||||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'],
|
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
|
||||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'],
|
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
|
||||||
['set_property', 'sid', 5],
|
['set_property', 'sid', 5],
|
||||||
['set_property', 'secondary-sid', 6],
|
['set_property', 'secondary-sid', 6],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
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<Array<string | number>> = [];
|
||||||
|
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<Array<string | number>> = [];
|
||||||
|
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<Array<string | number>> = [];
|
||||||
|
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 () => {
|
test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => {
|
||||||
const cleanupCalls: string[][] = [];
|
const cleanupCalls: string[][] = [];
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
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']]);
|
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles serializes overlapping preload runs', async () => {
|
||||||
|
let releaseFirstList!: () => void;
|
||||||
|
const firstListBlocked = new Promise<void>((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 () => {
|
test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => {
|
||||||
const cleanupCalls: string[][] = [];
|
const cleanupCalls: string[][] = [];
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
|||||||
@@ -23,10 +23,27 @@ type CachedSubtitleTrack = {
|
|||||||
cleanupDir: string;
|
cleanupDir: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
|
||||||
|
source: JellyfinSubtitleTrack;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MpvSubtitleTrack = {
|
||||||
|
id: number;
|
||||||
|
lang: string;
|
||||||
|
title: string;
|
||||||
|
external: boolean;
|
||||||
|
externalFilename: string;
|
||||||
|
};
|
||||||
|
|
||||||
type MpvClientLike = {
|
type MpvClientLike = {
|
||||||
|
connected?: boolean;
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TRACK_SELECTION_INITIAL_WAIT_MS = 250;
|
||||||
|
const TRACK_SELECTION_RETRY_MS = 150;
|
||||||
|
const TRACK_SELECTION_MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
|
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
|
||||||
session: JellyfinSession;
|
session: JellyfinSession;
|
||||||
clientInfo: JellyfinClientInfo;
|
clientInfo: JellyfinClientInfo;
|
||||||
@@ -71,17 +88,12 @@ function isLikelyHearingImpaired(title: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickBestTrackId(
|
function pickBestTrackId(
|
||||||
tracks: Array<{
|
tracks: MpvSubtitleTrack[],
|
||||||
id: number;
|
|
||||||
lang: string;
|
|
||||||
title: string;
|
|
||||||
external: boolean;
|
|
||||||
}>,
|
|
||||||
languageMatcher: (value: string) => boolean,
|
languageMatcher: (value: string) => boolean,
|
||||||
excludeId: number | null = null,
|
excludeId: number | null = null,
|
||||||
): number | null {
|
): number | null {
|
||||||
const ranked = tracks
|
const ranked = tracks
|
||||||
.filter((track) => languageMatcher(track.lang))
|
.filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
|
||||||
.filter((track) => track.id !== excludeId)
|
.filter((track) => track.id !== excludeId)
|
||||||
.map((track) => ({
|
.map((track) => ({
|
||||||
track,
|
track,
|
||||||
@@ -94,6 +106,119 @@ function pickBestTrackId(
|
|||||||
return ranked[0]?.track.id ?? null;
|
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<string, unknown> =>
|
||||||
|
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<MpvSubtitleTrack[] | null> {
|
||||||
|
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<void>;
|
||||||
|
},
|
||||||
|
shouldWaitForExternalJapanese: boolean,
|
||||||
|
expectedExternalFilenames: string[],
|
||||||
|
): Promise<MpvSubtitleTrack[] | null> {
|
||||||
|
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: {
|
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||||
listJellyfinSubtitleTracks: (
|
listJellyfinSubtitleTracks: (
|
||||||
session: JellyfinSession,
|
session: JellyfinSession,
|
||||||
@@ -108,6 +233,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
logDebug: (message: string, error: unknown) => void;
|
logDebug: (message: string, error: unknown) => void;
|
||||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||||
const activeCacheDirs = new Set<string>();
|
const activeCacheDirs = new Set<string>();
|
||||||
|
let preloadQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
function cleanupActiveCache(): void {
|
function cleanupActiveCache(): void {
|
||||||
const dirs = [...activeCacheDirs];
|
const dirs = [...activeCacheDirs];
|
||||||
@@ -116,7 +242,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
deps.cleanupCachedSubtitles(dirs);
|
deps.cleanupCachedSubtitles(dirs);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preload = async (params: {
|
const runPreload = async (params: {
|
||||||
session: JellyfinSession;
|
session: JellyfinSession;
|
||||||
clientInfo: JellyfinClientInfo;
|
clientInfo: JellyfinClientInfo;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -136,6 +262,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
|
|
||||||
await deps.wait(300);
|
await deps.wait(300);
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
|
const cachedTracks: CachedExternalSubtitleTrack[] = [];
|
||||||
for (const track of externalTracks) {
|
for (const track of externalTracks) {
|
||||||
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
|
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -145,36 +272,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||||
const cached = await deps.cacheSubtitleTrack(track);
|
const cached = await deps.cacheSubtitleTrack(track);
|
||||||
activeCacheDirs.add(cached.cleanupDir);
|
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);
|
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
|
||||||
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list');
|
const shouldWaitForExternalJapanese = externalTracks.some(
|
||||||
const subtitleTracks = Array.isArray(trackListRaw)
|
(track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
|
||||||
? trackListRaw
|
);
|
||||||
.filter(
|
const subtitleTracks = await waitForPreferredSubtitleTracks(
|
||||||
(track): track is Record<string, unknown> =>
|
deps,
|
||||||
Boolean(track) &&
|
shouldWaitForExternalJapanese,
|
||||||
typeof track === 'object' &&
|
cachedTracks.map((track) => track.path),
|
||||||
track.type === 'sub' &&
|
);
|
||||||
typeof track.id === 'number',
|
if (
|
||||||
)
|
shouldWaitForExternalJapanese &&
|
||||||
.map((track) => ({
|
(!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
|
||||||
id: track.id as number,
|
) {
|
||||||
lang: String(track.lang || ''),
|
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
|
||||||
title: String(track.title || ''),
|
itemId: params.itemId,
|
||||||
external: track.external === true,
|
});
|
||||||
}))
|
return;
|
||||||
: [];
|
}
|
||||||
|
|
||||||
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese);
|
const japanesePrimaryId =
|
||||||
|
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
|
||||||
|
pickBestTrackId(subtitleTracks ?? [], isJapanese);
|
||||||
if (japanesePrimaryId !== null) {
|
if (japanesePrimaryId !== null) {
|
||||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||||
} else {
|
} else {
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
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) {
|
if (englishSecondaryId !== null) {
|
||||||
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
|
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<void> => {
|
||||||
|
preloadQueue = preloadQueue.then(
|
||||||
|
() => runPreload(params),
|
||||||
|
() => runPreload(params),
|
||||||
|
);
|
||||||
|
return preloadQueue;
|
||||||
|
};
|
||||||
|
|
||||||
return Object.assign(preload, {
|
return Object.assign(preload, {
|
||||||
cleanupCachedSubtitles: cleanupActiveCache,
|
cleanupCachedSubtitles: cleanupActiveCache,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<boolean> } | 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<boolean> } | 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 () => {
|
test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinT
|
|||||||
| 'logger'
|
| 'logger'
|
||||||
| 'showMpvOsd'
|
| 'showMpvOsd'
|
||||||
>,
|
>,
|
||||||
|
options: { desiredActive?: boolean } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const activeSession = deps.getRemoteSession();
|
const activeSession = deps.getRemoteSession();
|
||||||
|
if (options.desiredActive === false) {
|
||||||
if (activeSession) {
|
if (activeSession) {
|
||||||
deps.stopRemoteSession();
|
deps.stopRemoteSession();
|
||||||
deps.logger.info('Jellyfin discovery stopped.');
|
deps.logger.info('Jellyfin discovery stopped.');
|
||||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||||
|
}
|
||||||
return;
|
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 });
|
await deps.startRemoteSession({ explicit: true });
|
||||||
const remoteSession = deps.getRemoteSession();
|
const remoteSession = deps.getRemoteSession();
|
||||||
if (!remoteSession) {
|
if (!remoteSession) {
|
||||||
|
|||||||
@@ -57,3 +57,33 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
|
|||||||
cleanup: resolved?.cleanup,
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ function parseTrackId(value: unknown): number | null {
|
|||||||
return 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(
|
function getActiveSubtitleTrack(
|
||||||
currentTrackRaw: unknown,
|
currentTrackRaw: unknown,
|
||||||
trackListRaw: unknown,
|
trackListRaw: unknown,
|
||||||
@@ -104,6 +113,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
|||||||
return { path: externalFilename, sourceKey: externalFilename };
|
return { path: externalFilename, sourceKey: externalFilename };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRemoteMediaPath(input.videoPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const extracted = await deps.extractInternalSubtitleTrack(
|
const extracted = await deps.extractInternalSubtitleTrack(
|
||||||
deps.getFfmpegPath(),
|
deps.getFfmpegPath(),
|
||||||
input.videoPath,
|
input.videoPath,
|
||||||
|
|||||||
@@ -43,6 +43,63 @@ test('ensure tray updates menu when tray already exists', () => {
|
|||||||
assert.deepEqual(calls, ['set-menu']);
|
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', () => {
|
test('ensure tray creates new tray and binds click handler', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let trayRef: unknown = null;
|
let trayRef: unknown = null;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
let initialized = false;
|
let initialized = false;
|
||||||
const buildTemplate = createBuildTrayMenuTemplateHandler({
|
const buildTemplate = createBuildTrayMenuTemplateHandler({
|
||||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||||
|
calls.push(`platform:${handlers.platform}`);
|
||||||
handlers.openSessionHelp();
|
handlers.openSessionHelp();
|
||||||
handlers.openTexthookerInBrowser();
|
handlers.openTexthookerInBrowser();
|
||||||
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
|
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
|
||||||
@@ -50,7 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
handlers.openYomitanSettings();
|
handlers.openYomitanSettings();
|
||||||
handlers.openConfigSettings();
|
handlers.openConfigSettings();
|
||||||
handlers.openJellyfinSetup();
|
handlers.openJellyfinSetup();
|
||||||
handlers.toggleJellyfinDiscovery();
|
handlers.toggleJellyfinDiscovery(true);
|
||||||
handlers.openAnilistSetup();
|
handlers.openAnilistSetup();
|
||||||
handlers.checkForUpdates();
|
handlers.checkForUpdates();
|
||||||
handlers.quitApp();
|
handlers.quitApp();
|
||||||
@@ -72,9 +73,10 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => true,
|
isJellyfinConfigured: () => true,
|
||||||
isJellyfinDiscoveryActive: () => false,
|
isJellyfinDiscoveryActive: () => false,
|
||||||
toggleJellyfinDiscovery: async () => {
|
toggleJellyfinDiscovery: async (checked) => {
|
||||||
calls.push('jellyfin-discovery');
|
calls.push(`jellyfin-discovery:${checked}`);
|
||||||
},
|
},
|
||||||
|
platform: 'linux',
|
||||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||||
checkForUpdates: () => calls.push('updates'),
|
checkForUpdates: () => calls.push('updates'),
|
||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
@@ -83,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
const template = buildTemplate();
|
const template = buildTemplate();
|
||||||
assert.deepEqual(template, [{ label: 'ok' }]);
|
assert.deepEqual(template, [{ label: 'ok' }]);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
|
'platform:linux',
|
||||||
'init',
|
'init',
|
||||||
'help',
|
'help',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
@@ -92,7 +95,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
'yomitan',
|
'yomitan',
|
||||||
'configuration',
|
'configuration',
|
||||||
'jellyfin',
|
'jellyfin',
|
||||||
'jellyfin-discovery',
|
'jellyfin-discovery:true',
|
||||||
'anilist',
|
'anilist',
|
||||||
'updates',
|
'updates',
|
||||||
'quit',
|
'quit',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function shouldShowTexthookerTrayEntry(config: {
|
|||||||
|
|
||||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||||
buildTrayMenuTemplateRuntime: (handlers: {
|
buildTrayMenuTemplateRuntime: (handlers: {
|
||||||
|
platform?: string;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: boolean;
|
showTexthookerPage: boolean;
|
||||||
@@ -49,7 +50,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
jellyfinDiscoveryActive: boolean;
|
jellyfinDiscoveryActive: boolean;
|
||||||
toggleJellyfinDiscovery: () => void;
|
toggleJellyfinDiscovery: (checked: boolean) => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
checkForUpdates: () => void;
|
checkForUpdates: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
@@ -67,13 +68,15 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
isJellyfinConfigured: () => boolean;
|
isJellyfinConfigured: () => boolean;
|
||||||
isJellyfinDiscoveryActive: () => boolean;
|
isJellyfinDiscoveryActive: () => boolean;
|
||||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
|
||||||
|
platform?: string;
|
||||||
openAnilistSetupWindow: () => void;
|
openAnilistSetupWindow: () => void;
|
||||||
checkForUpdates: () => void;
|
checkForUpdates: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): TMenuItem[] => {
|
return (): TMenuItem[] => {
|
||||||
return deps.buildTrayMenuTemplateRuntime({
|
return deps.buildTrayMenuTemplateRuntime({
|
||||||
|
platform: deps.platform,
|
||||||
openSessionHelp: () => {
|
openSessionHelp: () => {
|
||||||
if (!deps.isOverlayRuntimeInitialized()) {
|
if (!deps.isOverlayRuntimeInitialized()) {
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
@@ -103,8 +106,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
},
|
},
|
||||||
showJellyfinDiscovery: deps.isJellyfinConfigured(),
|
showJellyfinDiscovery: deps.isJellyfinConfigured(),
|
||||||
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
|
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
|
||||||
toggleJellyfinDiscovery: () => {
|
toggleJellyfinDiscovery: (checked) => {
|
||||||
void deps.toggleJellyfinDiscovery();
|
void deps.toggleJellyfinDiscovery(checked);
|
||||||
},
|
},
|
||||||
openAnilistSetup: () => {
|
openAnilistSetup: () => {
|
||||||
deps.openAnilistSetupWindow();
|
deps.openAnilistSetupWindow();
|
||||||
|
|||||||
@@ -35,15 +35,18 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => true,
|
isJellyfinConfigured: () => true,
|
||||||
isJellyfinDiscoveryActive: () => false,
|
isJellyfinDiscoveryActive: () => false,
|
||||||
toggleJellyfinDiscovery: () => {
|
toggleJellyfinDiscovery: (checked) => {
|
||||||
calls.push('jellyfin-discovery');
|
calls.push(`jellyfin-discovery:${checked}`);
|
||||||
},
|
},
|
||||||
|
platform: 'linux',
|
||||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||||
checkForUpdates: () => calls.push('updates'),
|
checkForUpdates: () => calls.push('updates'),
|
||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
assert.equal(menuDeps.platform, 'linux');
|
||||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||||
|
platform: menuDeps.platform,
|
||||||
openSessionHelp: () => calls.push('open-help'),
|
openSessionHelp: () => calls.push('open-help'),
|
||||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||||
showTexthookerPage: true,
|
showTexthookerPage: true,
|
||||||
@@ -56,7 +59,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
jellyfinDiscoveryActive: false,
|
jellyfinDiscoveryActive: false,
|
||||||
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
|
toggleJellyfinDiscovery: (checked) => calls.push(`open-jellyfin-discovery:${checked}`),
|
||||||
openAnilistSetup: () => calls.push('open-anilist'),
|
openAnilistSetup: () => calls.push('open-anilist'),
|
||||||
checkForUpdates: () => calls.push('open-updates'),
|
checkForUpdates: () => calls.push('open-updates'),
|
||||||
quitApp: () => calls.push('quit-app'),
|
quitApp: () => calls.push('quit-app'),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
|||||||
|
|
||||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||||
buildTrayMenuTemplateRuntime: (handlers: {
|
buildTrayMenuTemplateRuntime: (handlers: {
|
||||||
|
platform?: string;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: boolean;
|
showTexthookerPage: boolean;
|
||||||
@@ -39,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
jellyfinDiscoveryActive: boolean;
|
jellyfinDiscoveryActive: boolean;
|
||||||
toggleJellyfinDiscovery: () => void;
|
toggleJellyfinDiscovery: (checked: boolean) => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
checkForUpdates: () => void;
|
checkForUpdates: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
@@ -57,13 +58,15 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
isJellyfinConfigured: () => boolean;
|
isJellyfinConfigured: () => boolean;
|
||||||
isJellyfinDiscoveryActive: () => boolean;
|
isJellyfinDiscoveryActive: () => boolean;
|
||||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
|
||||||
|
platform?: string;
|
||||||
openAnilistSetupWindow: () => void;
|
openAnilistSetupWindow: () => void;
|
||||||
checkForUpdates: () => void;
|
checkForUpdates: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
|
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
|
||||||
|
platform: deps.platform,
|
||||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||||
openSessionHelpModal: deps.openSessionHelpModal,
|
openSessionHelpModal: deps.openSessionHelpModal,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
jellyfinDiscoveryActive: false,
|
jellyfinDiscoveryActive: false,
|
||||||
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
|
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
|
||||||
openAnilistSetup: () => calls.push('anilist'),
|
openAnilistSetup: () => calls.push('anilist'),
|
||||||
checkForUpdates: () => calls.push('updates'),
|
checkForUpdates: () => calls.push('updates'),
|
||||||
quitApp: () => calls.push('quit'),
|
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');
|
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
|
||||||
assert.equal(discovery?.type, 'checkbox');
|
assert.equal(discovery?.type, 'checkbox');
|
||||||
assert.equal(discovery?.checked, false);
|
assert.equal(discovery?.checked, false);
|
||||||
discovery?.click?.();
|
discovery?.click?.({ checked: true });
|
||||||
template[0]!.click?.();
|
template[0]!.click?.();
|
||||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||||
template[1]!.click?.();
|
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[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||||
template[11]!.click?.();
|
template[11]!.click?.();
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'jellyfin-discovery',
|
'jellyfin-discovery:true',
|
||||||
'help',
|
'help',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
'updates',
|
'updates',
|
||||||
@@ -155,3 +155,29 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
|||||||
assert.equal(discovery?.type, 'checkbox');
|
assert.equal(discovery?.type, 'checkbox');
|
||||||
assert.equal(discovery?.checked, true);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TrayMenuActionHandlers = {
|
export type TrayMenuActionHandlers = {
|
||||||
|
platform?: string;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: boolean;
|
showTexthookerPage: boolean;
|
||||||
@@ -42,19 +43,28 @@ export type TrayMenuActionHandlers = {
|
|||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
jellyfinDiscoveryActive: boolean;
|
jellyfinDiscoveryActive: boolean;
|
||||||
toggleJellyfinDiscovery: () => void;
|
toggleJellyfinDiscovery: (checked: boolean) => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
checkForUpdates: () => void;
|
checkForUpdates: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TrayMenuClickItem = {
|
||||||
|
checked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
||||||
label?: string;
|
label?: string;
|
||||||
type?: 'separator' | 'checkbox';
|
type?: 'separator' | 'checkbox';
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
click?: () => void;
|
click?: (menuItem?: TrayMenuClickItem) => void;
|
||||||
}> {
|
}> {
|
||||||
|
const jellyfinDiscoveryLabel =
|
||||||
|
handlers.platform === 'linux' && handlers.jellyfinDiscoveryActive
|
||||||
|
? '✓ Jellyfin Discovery'
|
||||||
|
: 'Jellyfin Discovery';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Open Help',
|
label: 'Open Help',
|
||||||
@@ -99,11 +109,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
|||||||
...(handlers.showJellyfinDiscovery
|
...(handlers.showJellyfinDiscovery
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: 'Jellyfin Discovery',
|
label: jellyfinDiscoveryLabel,
|
||||||
type: 'checkbox' as const,
|
type: 'checkbox' as const,
|
||||||
checked: handlers.jellyfinDiscoveryActive,
|
checked: handlers.jellyfinDiscoveryActive,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
click: handlers.toggleJellyfinDiscovery,
|
click: (menuItem?: TrayMenuClickItem) => {
|
||||||
|
const checked =
|
||||||
|
typeof menuItem?.checked === 'boolean'
|
||||||
|
? menuItem.checked
|
||||||
|
: !handlers.jellyfinDiscoveryActive;
|
||||||
|
handlers.toggleJellyfinDiscovery(checked);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -244,3 +244,29 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
|
|||||||
harness.restoreGlobals();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,8 +105,16 @@ export function createSubsyncModal(
|
|||||||
|
|
||||||
async function runSubsyncManualFromModal(): Promise<void> {
|
async function runSubsyncManualFromModal(): Promise<void> {
|
||||||
if (ctx.state.subsyncSubmitting) return;
|
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 =
|
const sourceTrackId =
|
||||||
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
|
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
|
||||||
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
||||||
|
|||||||
@@ -300,14 +300,10 @@ export interface ResolvedConfig {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
recentServers: string[];
|
recentServers: string[];
|
||||||
username: string;
|
username: string;
|
||||||
deviceId: string;
|
|
||||||
clientName: string;
|
|
||||||
clientVersion: string;
|
|
||||||
defaultLibraryId: string;
|
defaultLibraryId: string;
|
||||||
remoteControlEnabled: boolean;
|
remoteControlEnabled: boolean;
|
||||||
remoteControlAutoConnect: boolean;
|
remoteControlAutoConnect: boolean;
|
||||||
autoAnnounce: boolean;
|
autoAnnounce: boolean;
|
||||||
remoteControlDeviceName: string;
|
|
||||||
pullPictures: boolean;
|
pullPictures: boolean;
|
||||||
iconCacheDir: string;
|
iconCacheDir: string;
|
||||||
directPlayPreferred: boolean;
|
directPlayPreferred: boolean;
|
||||||
|
|||||||
@@ -87,14 +87,10 @@ export interface JellyfinConfig {
|
|||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
recentServers?: string[];
|
recentServers?: string[];
|
||||||
username?: string;
|
username?: string;
|
||||||
deviceId?: string;
|
|
||||||
clientName?: string;
|
|
||||||
clientVersion?: string;
|
|
||||||
defaultLibraryId?: string;
|
defaultLibraryId?: string;
|
||||||
remoteControlEnabled?: boolean;
|
remoteControlEnabled?: boolean;
|
||||||
remoteControlAutoConnect?: boolean;
|
remoteControlAutoConnect?: boolean;
|
||||||
autoAnnounce?: boolean;
|
autoAnnounce?: boolean;
|
||||||
remoteControlDeviceName?: string;
|
|
||||||
pullPictures?: boolean;
|
pullPictures?: boolean;
|
||||||
iconCacheDir?: string;
|
iconCacheDir?: string;
|
||||||
directPlayPreferred?: boolean;
|
directPlayPreferred?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user