fix(config): remove trailing commas from config.example.jsonc

- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl
This commit is contained in:
2026-05-22 02:07:10 -07:00
parent 58fd648185
commit 83fdccb752
7 changed files with 371 additions and 174 deletions
+147 -81
View File
@@ -5,6 +5,7 @@
* 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.
@@ -18,7 +19,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.
// ========================================== // ==========================================
@@ -28,7 +29,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.
// ========================================== // ==========================================
@@ -38,7 +39,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.
// ========================================== // ==========================================
@@ -48,7 +49,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.
// ========================================== // ==========================================
@@ -81,66 +82,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.
// ========================================== // ==========================================
@@ -154,7 +155,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.
// ========================================== // ==========================================
@@ -166,7 +167,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.
// ========================================== // ==========================================
@@ -192,7 +193,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.
// ========================================== // ==========================================
@@ -204,76 +205,122 @@
"keybindings": [ "keybindings": [
{ {
"key": "Space", // Key setting. "key": "Space", // Key setting.
"command": ["cycle", "pause"], // Command setting. "command": [
"cycle",
"pause"
] // Command setting.
}, },
{ {
"key": "KeyF", // Key setting. "key": "KeyF", // Key setting.
"command": ["cycle", "fullscreen"], // Command setting. "command": [
"cycle",
"fullscreen"
] // Command setting.
}, },
{ {
"key": "KeyJ", // Key setting. "key": "KeyJ", // Key setting.
"command": ["cycle", "sid"], // Command setting. "command": [
"cycle",
"sid"
] // Command setting.
}, },
{ {
"key": "Shift+KeyJ", // Key setting. "key": "Shift+KeyJ", // Key setting.
"command": ["cycle", "secondary-sid"], // Command setting. "command": [
"cycle",
"secondary-sid"
] // Command setting.
}, },
{ {
"key": "ArrowRight", // Key setting. "key": "ArrowRight", // Key setting.
"command": ["seek", 5], // Command setting. "command": [
"seek",
5
] // Command setting.
}, },
{ {
"key": "ArrowLeft", // Key setting. "key": "ArrowLeft", // Key setting.
"command": ["seek", -5], // Command setting. "command": [
"seek",
-5
] // Command setting.
}, },
{ {
"key": "ArrowUp", // Key setting. "key": "ArrowUp", // Key setting.
"command": ["seek", 60], // Command setting. "command": [
"seek",
60
] // Command setting.
}, },
{ {
"key": "ArrowDown", // Key setting. "key": "ArrowDown", // Key setting.
"command": ["seek", -60], // Command setting. "command": [
"seek",
-60
] // Command setting.
}, },
{ {
"key": "Shift+KeyH", // Key setting. "key": "Shift+KeyH", // Key setting.
"command": ["sub-seek", -1], // Command setting. "command": [
"sub-seek",
-1
] // Command setting.
}, },
{ {
"key": "Shift+KeyL", // Key setting. "key": "Shift+KeyL", // Key setting.
"command": ["sub-seek", 1], // Command setting. "command": [
"sub-seek",
1
] // Command setting.
}, },
{ {
"key": "Shift+BracketRight", // Key setting. "key": "Shift+BracketRight", // Key setting.
"command": ["__sub-delay-next-line"], // Command setting. "command": [
"__sub-delay-next-line"
] // Command setting.
}, },
{ {
"key": "Shift+BracketLeft", // Key setting. "key": "Shift+BracketLeft", // Key setting.
"command": ["__sub-delay-prev-line"], // Command setting. "command": [
"__sub-delay-prev-line"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyC", // Key setting. "key": "Ctrl+Alt+KeyC", // Key setting.
"command": ["__youtube-picker-open"], // Command setting. "command": [
"__youtube-picker-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyP", // Key setting. "key": "Ctrl+Alt+KeyP", // Key setting.
"command": ["__playlist-browser-open"], // Command setting. "command": [
"__playlist-browser-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyH", // Key setting. "key": "Ctrl+Shift+KeyH", // Key setting.
"command": ["__replay-subtitle"], // Command setting. "command": [
"__replay-subtitle"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyL", // Key setting. "key": "Ctrl+Shift+KeyL", // Key setting.
"command": ["__play-next-subtitle"], // Command setting. "command": [
"__play-next-subtitle"
] // Command setting.
}, },
{ {
"key": "KeyQ", // Key setting. "key": "KeyQ", // Key setting.
"command": ["quit"], // Command setting. "command": [
"quit"
] // Command setting.
}, },
{ {
"key": "Ctrl+KeyW", // Key setting. "key": "Ctrl+KeyW", // Key setting.
"command": ["quit"], // Command setting. "command": [
}, "quit"
] // Command setting.
}
], // Default and custom keybindings that are merged with built-in defaults. ], // Default and custom keybindings that are merged with built-in defaults.
// ========================================== // ==========================================
@@ -285,7 +332,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.
// ========================================== // ==========================================
@@ -297,7 +344,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.
// ========================================== // ==========================================
@@ -305,7 +352,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.
// ========================================== // ==========================================
@@ -330,7 +377,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
@@ -345,7 +392,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
@@ -354,7 +401,13 @@
"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": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). "bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"css": { "css": {
@@ -370,9 +423,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.
// ========================================== // ==========================================
@@ -397,8 +450,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.
// ========================================== // ==========================================
@@ -412,7 +465,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.
// ========================================== // ==========================================
@@ -430,21 +483,23 @@
"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": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "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
@@ -458,14 +513,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
@@ -473,24 +528,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.
// ========================================== // ==========================================
@@ -501,7 +556,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.
// ========================================== // ==========================================
@@ -510,7 +565,10 @@
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ========================================== // ==========================================
"youtube": { "youtube": {
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. "primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
@@ -531,9 +589,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.
// ========================================== // ==========================================
@@ -544,7 +602,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.
// ========================================== // ==========================================
@@ -564,7 +622,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.
// ========================================== // ==========================================
@@ -585,8 +643,16 @@
"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": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. "directPlayContainers": [
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. "mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -598,7 +664,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.
// ========================================== // ==========================================
@@ -623,13 +689,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.
// ========================================== // ==========================================
@@ -642,6 +708,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.
} }
+147 -81
View File
@@ -5,6 +5,7 @@
* 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.
@@ -18,7 +19,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.
// ========================================== // ==========================================
@@ -28,7 +29,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.
// ========================================== // ==========================================
@@ -38,7 +39,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.
// ========================================== // ==========================================
@@ -48,7 +49,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.
// ========================================== // ==========================================
@@ -81,66 +82,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.
// ========================================== // ==========================================
@@ -154,7 +155,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.
// ========================================== // ==========================================
@@ -166,7 +167,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.
// ========================================== // ==========================================
@@ -192,7 +193,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.
// ========================================== // ==========================================
@@ -204,76 +205,122 @@
"keybindings": [ "keybindings": [
{ {
"key": "Space", // Key setting. "key": "Space", // Key setting.
"command": ["cycle", "pause"], // Command setting. "command": [
"cycle",
"pause"
] // Command setting.
}, },
{ {
"key": "KeyF", // Key setting. "key": "KeyF", // Key setting.
"command": ["cycle", "fullscreen"], // Command setting. "command": [
"cycle",
"fullscreen"
] // Command setting.
}, },
{ {
"key": "KeyJ", // Key setting. "key": "KeyJ", // Key setting.
"command": ["cycle", "sid"], // Command setting. "command": [
"cycle",
"sid"
] // Command setting.
}, },
{ {
"key": "Shift+KeyJ", // Key setting. "key": "Shift+KeyJ", // Key setting.
"command": ["cycle", "secondary-sid"], // Command setting. "command": [
"cycle",
"secondary-sid"
] // Command setting.
}, },
{ {
"key": "ArrowRight", // Key setting. "key": "ArrowRight", // Key setting.
"command": ["seek", 5], // Command setting. "command": [
"seek",
5
] // Command setting.
}, },
{ {
"key": "ArrowLeft", // Key setting. "key": "ArrowLeft", // Key setting.
"command": ["seek", -5], // Command setting. "command": [
"seek",
-5
] // Command setting.
}, },
{ {
"key": "ArrowUp", // Key setting. "key": "ArrowUp", // Key setting.
"command": ["seek", 60], // Command setting. "command": [
"seek",
60
] // Command setting.
}, },
{ {
"key": "ArrowDown", // Key setting. "key": "ArrowDown", // Key setting.
"command": ["seek", -60], // Command setting. "command": [
"seek",
-60
] // Command setting.
}, },
{ {
"key": "Shift+KeyH", // Key setting. "key": "Shift+KeyH", // Key setting.
"command": ["sub-seek", -1], // Command setting. "command": [
"sub-seek",
-1
] // Command setting.
}, },
{ {
"key": "Shift+KeyL", // Key setting. "key": "Shift+KeyL", // Key setting.
"command": ["sub-seek", 1], // Command setting. "command": [
"sub-seek",
1
] // Command setting.
}, },
{ {
"key": "Shift+BracketRight", // Key setting. "key": "Shift+BracketRight", // Key setting.
"command": ["__sub-delay-next-line"], // Command setting. "command": [
"__sub-delay-next-line"
] // Command setting.
}, },
{ {
"key": "Shift+BracketLeft", // Key setting. "key": "Shift+BracketLeft", // Key setting.
"command": ["__sub-delay-prev-line"], // Command setting. "command": [
"__sub-delay-prev-line"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyC", // Key setting. "key": "Ctrl+Alt+KeyC", // Key setting.
"command": ["__youtube-picker-open"], // Command setting. "command": [
"__youtube-picker-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyP", // Key setting. "key": "Ctrl+Alt+KeyP", // Key setting.
"command": ["__playlist-browser-open"], // Command setting. "command": [
"__playlist-browser-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyH", // Key setting. "key": "Ctrl+Shift+KeyH", // Key setting.
"command": ["__replay-subtitle"], // Command setting. "command": [
"__replay-subtitle"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyL", // Key setting. "key": "Ctrl+Shift+KeyL", // Key setting.
"command": ["__play-next-subtitle"], // Command setting. "command": [
"__play-next-subtitle"
] // Command setting.
}, },
{ {
"key": "KeyQ", // Key setting. "key": "KeyQ", // Key setting.
"command": ["quit"], // Command setting. "command": [
"quit"
] // Command setting.
}, },
{ {
"key": "Ctrl+KeyW", // Key setting. "key": "Ctrl+KeyW", // Key setting.
"command": ["quit"], // Command setting. "command": [
}, "quit"
] // Command setting.
}
], // Default and custom keybindings that are merged with built-in defaults. ], // Default and custom keybindings that are merged with built-in defaults.
// ========================================== // ==========================================
@@ -285,7 +332,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.
// ========================================== // ==========================================
@@ -297,7 +344,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.
// ========================================== // ==========================================
@@ -305,7 +352,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.
// ========================================== // ==========================================
@@ -330,7 +377,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
@@ -345,7 +392,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
@@ -354,7 +401,13 @@
"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": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). "bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"css": { "css": {
@@ -370,9 +423,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.
// ========================================== // ==========================================
@@ -397,8 +450,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.
// ========================================== // ==========================================
@@ -412,7 +465,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.
// ========================================== // ==========================================
@@ -430,21 +483,23 @@
"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": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "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
@@ -458,14 +513,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
@@ -473,24 +528,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.
// ========================================== // ==========================================
@@ -501,7 +556,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.
// ========================================== // ==========================================
@@ -510,7 +565,10 @@
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ========================================== // ==========================================
"youtube": { "youtube": {
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. "primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
@@ -531,9 +589,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.
// ========================================== // ==========================================
@@ -544,7 +602,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.
// ========================================== // ==========================================
@@ -564,7 +622,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.
// ========================================== // ==========================================
@@ -585,8 +643,16 @@
"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": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. "directPlayContainers": [
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. "mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -598,7 +664,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.
// ========================================== // ==========================================
@@ -623,13 +689,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.
// ========================================== // ==========================================
@@ -642,6 +708,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.
} }
@@ -267,3 +267,46 @@ test('playback handler does not let stats metadata failures block playback start
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
}); });
test('playback handler does not let media title 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 4',
itemTitle: 'Episode 4',
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: () => {},
updateCurrentMediaTitle: () => {
throw new Error('title state unavailable');
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-4',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
});
+2 -2
View File
@@ -107,8 +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 { try {
deps.updateCurrentMediaTitle?.(plan.title);
deps.recordJellyfinPlaybackMetadata?.({ deps.recordJellyfinPlaybackMetadata?.({
mediaPath: playbackUrl, mediaPath: playbackUrl,
displayTitle: plan.title, displayTitle: plan.title,
@@ -119,7 +119,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
itemId: params.itemId, itemId: params.itemId,
}); });
} catch { } catch {
// Best-effort stats metadata must not block playback startup. // Best-effort metadata/title hooks must not block playback startup.
} }
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) { if (params.setQuitOnDisconnectArm !== false) {
@@ -331,6 +331,35 @@ 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 logs cleanup failures without rejecting', async () => {
const logs: string[] = [];
let cleanupShouldFail = false;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({ requestProperty: async () => [] }),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`,
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
}),
cleanupCachedSubtitles: () => {
if (cleanupShouldFail) {
throw new Error('cleanup failed');
}
},
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
cleanupShouldFail = true;
await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' }));
assert.deepEqual(logs, ['Failed to preload Jellyfin external subtitles']);
});
test('preload jellyfin subtitles serializes overlapping preload runs', async () => { test('preload jellyfin subtitles serializes overlapping preload runs', async () => {
let releaseFirstList!: () => void; let releaseFirstList!: () => void;
const firstListBlocked = new Promise<void>((resolve) => { const firstListBlocked = new Promise<void>((resolve) => {
@@ -174,9 +174,7 @@ function hasExpectedExternalSubtitleTracks(
return true; return true;
} }
const loadedExternalFilenames = new Set( const loadedExternalFilenames = new Set(
tracks tracks.filter((track) => track.externalFilename).map((track) => track.externalFilename),
.filter((track) => track.externalFilename)
.map((track) => track.externalFilename),
); );
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath)); return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
} }
@@ -247,9 +245,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
clientInfo: JellyfinClientInfo; clientInfo: JellyfinClientInfo;
itemId: string; itemId: string;
}): Promise<void> => { }): Promise<void> => {
cleanupActiveCache();
try { try {
cleanupActiveCache();
const tracks = await deps.listJellyfinSubtitleTracks( const tracks = await deps.listJellyfinSubtitleTracks(
params.session, params.session,
params.clientInfo, params.clientInfo,
@@ -390,9 +390,5 @@ test('manual update check keeps current prerelease builds on configured stable c
const result = await service.checkForUpdates({ source: 'manual' }); const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'up-to-date'); assert.equal(result.status, 'up-to-date');
assert.deepEqual(calls, [ assert.deepEqual(calls, ['app:stable', 'fetch:stable', 'no-update:0.15.0-beta.3']);
'app:stable',
'fetch:stable',
'no-update:0.15.0-beta.3',
]);
}); });