feat(config): hot-reload safe config updates and document behavior

This commit is contained in:
2026-02-18 01:04:56 -08:00
parent fd49e73762
commit 4703b995da
18 changed files with 850 additions and 85 deletions

View File

@@ -18,6 +18,7 @@
- **Subtitle Download & Sync** — Search Jimaku, sync with alass or ffsubsync — all from the player - **Subtitle Download & Sync** — Search Jimaku, sync with alass or ffsubsync — all from the player
- **Queue Control In-Player** — Drop videos on overlay to load/queue in mpv; `Ctrl/Cmd+A` appends clipboard path - **Queue Control In-Player** — Drop videos on overlay to load/queue in mpv; `Ctrl/Cmd+A` appends clipboard path
- **Keyboard-Driven** — Mine, copy, cycle display modes, and navigate from configurable shortcuts - **Keyboard-Driven** — Mine, copy, cycle display modes, and navigate from configurable shortcuts
- **Config Hot Reload** — Apply subtitle style and shortcut updates from `config.jsonc` without restarting
- **Japanese Tokenization** — MeCab-powered word boundary detection with smart grouping - **Japanese Tokenization** — MeCab-powered word boundary detection with smart grouping
## Requirements ## Requirements
@@ -68,6 +69,8 @@ For macOS builds and platform details, see the [installation docs](docs/installa
subminer video.mkv subminer video.mkv
``` ```
Config tip: while SubMiner is running, safe config edits (subtitle style, keybindings, shortcuts, secondary subtitle default mode, and `ankiConnect.ai`) hot-reload automatically.
```bash ```bash
subminer # pick video from cwd (fzf) subminer # pick video from cwd (fzf)
subminer -R # rofi picker subminer -R # rofi picker

View File

@@ -2,7 +2,8 @@
id: TASK-39 id: TASK-39
title: Add hot-reload for non-destructive config changes title: Add hot-reload for non-destructive config changes
status: Done status: Done
assignee: [] assignee:
- '@sudacode'
created_date: '2026-02-14 02:04' created_date: '2026-02-14 02:04'
updated_date: '2026-02-18 09:29' updated_date: '2026-02-18 09:29'
labels: labels:
@@ -10,6 +11,13 @@ labels:
- developer-experience - developer-experience
- quality-of-life - quality-of-life
dependencies: [] dependencies: []
references:
- docs/plans/2026-02-18-task-39-hot-reload-config.md
- README.md
- docs/configuration.md
- docs/usage.md
- config.example.jsonc
- docs/public/config.example.jsonc
priority: low priority: low
ordinal: 59000 ordinal: 59000
--- ---
@@ -58,3 +66,65 @@ Currently all config is loaded at startup. Users tweaking visual settings (font
- [x] #5 Invalid config changes are rejected with an error notification, keeping the previous valid config. - [x] #5 Invalid config changes are rejected with an error notification, keeping the previous valid config.
- [x] #6 Renderer receives updated styles/settings via IPC without full page reload. - [x] #6 Renderer receives updated styles/settings via IPC without full page reload.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Implementation plan documented in `docs/plans/2026-02-18-task-39-hot-reload-config.md`.
Execution breakdown:
1. Add strict config reload API in `ConfigService` so invalid JSON/JSONC updates are rejected without mutating current runtime config.
2. Add testable hot-reload coordinator service with debounced file-change handling and diff classification (hot-reload vs restart-required fields).
3. Wire coordinator into main process watcher lifecycle; apply hot changes (style/keybindings/shortcuts/secondary mode/anki AI), emit restart-needed notification for non-hot changes, and emit invalid-config notification.
4. Add renderer/preload live update IPC hooks for style/keybinding refresh without page reload.
5. Run build + targeted tests (`test:fast`) and validate criteria with notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented strict config reload path in `ConfigService` (`reloadConfigStrict`) to reject invalid JSON/JSONC while preserving the last valid in-memory config.
Added `createConfigHotReloadRuntime` with debounced watcher-driven reloads, hot-vs-restart diff classification, and invalid-config callback handling.
Wired runtime into main process startup/teardown with `fs.watch`-based config path monitoring (file or config dir fallback), hot update application, restart-needed notifications, and invalid-config notifications.
Added renderer IPC hot-update channel (`config:hot-reload`) through preload/types, updating keybindings map, subtitle style, and secondary subtitle mode without page reload.
Updated core/config tests and test runner list to cover strict reload behavior and new hot-reload runtime service.
Updated user-facing docs to describe hot-reload behavior and restart-required notifications in `README.md`, `docs/configuration.md`, and `docs/usage.md`.
Regenerated `config.example.jsonc` and docs-served `docs/public/config.example.jsonc` from updated template metadata so hot-reload notes appear inline in config sections.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented TASK-39 by introducing a debounced config hot-reload pipeline that watches config changes, safely reloads with strict JSON/JSONC validation, and applies non-destructive runtime updates without requiring a full app restart.
### What changed
- Added `reloadConfigStrict()` to `ConfigService` so malformed config edits are rejected and the previous valid runtime config remains active.
- Added new core runtime service `createConfigHotReloadRuntime` to:
- debounce rapid save bursts,
- classify changed fields into hot-reloadable vs restart-required,
- route invalid reload errors to user-visible notifications.
- Wired hot-reload runtime into `main.ts` app lifecycle:
- starts after initial config load,
- stops on app quit,
- applies hot updates for subtitle styling, keybindings, shortcuts, secondary subtitle mode defaults, and Anki AI config patching.
- Added renderer live-update IPC channel (`config:hot-reload`) via `preload.ts` + `types.ts`, and applied updates in renderer without page reload.
- Added/updated tests:
- strict reload behavior in `src/config/config.test.ts`,
- hot-reload diff/debounce/invalid handling in `src/core/services/config-hot-reload.test.ts`,
- included new test file in `test:core:dist` script.
### Validation
- `bun run build && node --test dist/config/config.test.js`
- `bun run build && node --test dist/core/services/config-hot-reload.test.js`
- `bun run test:fast`
All above commands passed.
Added follow-up documentation coverage for the shipped feature: README feature/quickstart note, configuration hot-reload behavior section, usage guide live-reload section, and regenerated config templates with per-section hot-reload notes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -23,7 +24,7 @@
// Control whether browser opens automatically for texthooker. // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"openBrowser": true, "openBrowser": true
}, },
// ========================================== // ==========================================
@@ -33,7 +34,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", "enabled": "auto",
"port": 6677, "port": 6677
}, },
// ========================================== // ==========================================
@@ -42,12 +43,14 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info", "level": "info"
}, },
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: AI translation settings update live while SubMiner is running.
// Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
"ankiConnect": { "ankiConnect": {
"enabled": false, "enabled": false,
@@ -58,7 +61,7 @@
"image": "Picture", "image": "Picture",
"sentence": "Sentence", "sentence": "Sentence",
"miscInfo": "MiscInfo", "miscInfo": "MiscInfo",
"translation": "SelectionText", "translation": "SelectionText"
}, },
"ai": { "ai": {
"enabled": false, "enabled": false,
@@ -67,7 +70,7 @@
"model": "openai/gpt-4o-mini", "model": "openai/gpt-4o-mini",
"baseUrl": "https://openrouter.ai/api", "baseUrl": "https://openrouter.ai/api",
"targetLanguage": "English", "targetLanguage": "English",
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", "systemPrompt": "You are a translation engine. Return only the translated text with no explanations."
}, },
"media": { "media": {
"generateAudio": true, "generateAudio": true,
@@ -80,7 +83,7 @@
"animatedCrf": 35, "animatedCrf": 35,
"audioPadding": 0.5, "audioPadding": 0.5,
"fallbackDuration": 3, "fallbackDuration": 3,
"maxMediaDuration": 30, "maxMediaDuration": 30
}, },
"behavior": { "behavior": {
"overwriteAudio": true, "overwriteAudio": true,
@@ -88,7 +91,7 @@
"mediaInsertMode": "append", "mediaInsertMode": "append",
"highlightWord": true, "highlightWord": true,
"notificationType": "osd", "notificationType": "osd",
"autoUpdateNewCards": true, "autoUpdateNewCards": true
}, },
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, "highlightEnabled": false,
@@ -97,22 +100,22 @@
"decks": [], "decks": [],
"minSentenceWords": 3, "minSentenceWords": 3,
"nPlusOne": "#c6a0f6", "nPlusOne": "#c6a0f6",
"knownWord": "#a6da95", "knownWord": "#a6da95"
}, },
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)", "pattern": "[SubMiner] %f (%t)"
}, },
"isLapis": { "isLapis": {
"enabled": false, "enabled": false,
"sentenceCardModel": "Japanese sentences", "sentenceCardModel": "Japanese sentences",
"sentenceCardSentenceField": "Sentence", "sentenceCardSentenceField": "Sentence",
"sentenceCardAudioField": "SentenceAudio", "sentenceCardAudioField": "SentenceAudio"
}, },
"isKiku": { "isKiku": {
"enabled": false, "enabled": false,
"fieldGrouping": "disabled", "fieldGrouping": "disabled",
"deleteDuplicateInAuto": true, "deleteDuplicateInAuto": true
}, }
}, },
// ========================================== // ==========================================
@@ -120,6 +123,7 @@
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Fixed (non-configurable) overlay shortcuts: // Fixed (non-configurable) overlay shortcuts:
// - Ctrl/Cmd+A: append clipboard video path to MPV playlist // - Ctrl/Cmd+A: append clipboard video path to MPV playlist
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", "toggleVisibleOverlayGlobal": "Alt+Shift+O",
@@ -135,7 +139,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", "toggleSecondarySub": "CommandOrControl+Shift+V",
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J"
}, },
// ========================================== // ==========================================
@@ -145,19 +149,21 @@
// This edit-mode shortcut is fixed and is not currently configurable. // This edit-mode shortcut is fixed and is not currently configurable.
// ========================================== // ==========================================
"invisibleOverlay": { "invisibleOverlay": {
"startupVisibility": "platform-default", "startupVisibility": "platform-default"
}, },
// ========================================== // ==========================================
// Keybindings (MPV Commands) // Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults. // Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding. // Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"keybindings": [], "keybindings": [],
// ========================================== // ==========================================
// Subtitle Appearance // Subtitle Appearance
// Primary and secondary subtitle styling. // Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, "enableJlpt": false,
@@ -174,7 +180,7 @@
"N2": "#f5a97f", "N2": "#f5a97f",
"N3": "#f9e2af", "N3": "#f9e2af",
"N4": "#a6e3a1", "N4": "#a6e3a1",
"N5": "#8aadf4", "N5": "#8aadf4"
}, },
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, "enabled": false,
@@ -182,7 +188,13 @@
"topX": 1000, "topX": 1000,
"mode": "single", "mode": "single",
"singleColor": "#f5a97f", "singleColor": "#f5a97f",
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], "bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
]
}, },
"secondary": { "secondary": {
"fontSize": 24, "fontSize": 24,
@@ -190,19 +202,20 @@
"backgroundColor": "transparent", "backgroundColor": "transparent",
"fontWeight": "normal", "fontWeight": "normal",
"fontStyle": "normal", "fontStyle": "normal",
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif"
}, }
}, },
// ========================================== // ==========================================
// Secondary Subtitles // Secondary Subtitles
// Dual subtitle track options. // Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences. // Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ========================================== // ==========================================
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], "secondarySubLanguages": [],
"autoLoadSecondarySub": false, "autoLoadSecondarySub": false,
"defaultMode": "hover", "defaultMode": "hover"
}, },
// ========================================== // ==========================================
@@ -213,7 +226,7 @@
"defaultMode": "auto", "defaultMode": "auto",
"alass_path": "", "alass_path": "",
"ffsubsync_path": "", "ffsubsync_path": "",
"ffmpeg_path": "", "ffmpeg_path": ""
}, },
// ========================================== // ==========================================
@@ -221,7 +234,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10, "yPercent": 10
}, },
// ========================================== // ==========================================
@@ -231,7 +244,7 @@
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja", "languagePreference": "ja",
"maxEntryResults": 10, "maxEntryResults": 10
}, },
// ========================================== // ==========================================
@@ -242,7 +255,10 @@
"mode": "automatic", "mode": "automatic",
"whisperBin": "", "whisperBin": "",
"whisperModel": "", "whisperModel": "",
"primarySubLanguages": ["ja", "jpn"], "primarySubLanguages": [
"ja",
"jpn"
]
}, },
// ========================================== // ==========================================
@@ -251,7 +267,7 @@
// ========================================== // ==========================================
"anilist": { "anilist": {
"enabled": false, "enabled": false,
"accessToken": "", "accessToken": ""
}, },
// ========================================== // ==========================================
@@ -276,8 +292,16 @@
"pullPictures": false, "pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true, "directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": [
"transcodeVideoCodec": "h264", "mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
],
"transcodeVideoCodec": "h264"
}, },
// ========================================== // ==========================================
@@ -299,7 +323,7 @@
"telemetryDays": 30, "telemetryDays": 30,
"dailyRollupsDays": 365, "dailyRollupsDays": 365,
"monthlyRollupsDays": 1825, "monthlyRollupsDays": 1825,
"vacuumIntervalDays": 7, "vacuumIntervalDays": 7
}, }
}, }
} }

View File

@@ -42,6 +42,25 @@ SubMiner.AppImage --generate-config --backup-overwrite
Invalid config values are handled with warn-and-fallback behavior: SubMiner logs the bad key/value and continues with the default for that option. Invalid config values are handled with warn-and-fallback behavior: SubMiner logs the bad key/value and continues with the default for that option.
### Hot-Reload Behavior
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
Hot-reloadable fields:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
Restart-required changes:
- Any other config sections still require restart.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview ### Configuration Options Overview
The configuration file includes several main sections: The configuration file includes several main sections:
@@ -296,6 +315,8 @@ The list is generated at runtime from:
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values). - Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
- Current subtitle color settings from `subtitleStyle`. - Current subtitle color settings from `subtitleStyle`.
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
### Auto-Start Overlay ### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv: Control whether the overlay automatically becomes visible when it connects to mpv:

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -23,7 +24,7 @@
// Control whether browser opens automatically for texthooker. // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"openBrowser": true, "openBrowser": true
}, },
// ========================================== // ==========================================
@@ -33,7 +34,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", "enabled": "auto",
"port": 6677, "port": 6677
}, },
// ========================================== // ==========================================
@@ -42,12 +43,14 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info", "level": "info"
}, },
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: AI translation settings update live while SubMiner is running.
// Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
"ankiConnect": { "ankiConnect": {
"enabled": false, "enabled": false,
@@ -58,7 +61,7 @@
"image": "Picture", "image": "Picture",
"sentence": "Sentence", "sentence": "Sentence",
"miscInfo": "MiscInfo", "miscInfo": "MiscInfo",
"translation": "SelectionText", "translation": "SelectionText"
}, },
"ai": { "ai": {
"enabled": false, "enabled": false,
@@ -67,7 +70,7 @@
"model": "openai/gpt-4o-mini", "model": "openai/gpt-4o-mini",
"baseUrl": "https://openrouter.ai/api", "baseUrl": "https://openrouter.ai/api",
"targetLanguage": "English", "targetLanguage": "English",
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", "systemPrompt": "You are a translation engine. Return only the translated text with no explanations."
}, },
"media": { "media": {
"generateAudio": true, "generateAudio": true,
@@ -80,7 +83,7 @@
"animatedCrf": 35, "animatedCrf": 35,
"audioPadding": 0.5, "audioPadding": 0.5,
"fallbackDuration": 3, "fallbackDuration": 3,
"maxMediaDuration": 30, "maxMediaDuration": 30
}, },
"behavior": { "behavior": {
"overwriteAudio": true, "overwriteAudio": true,
@@ -88,7 +91,7 @@
"mediaInsertMode": "append", "mediaInsertMode": "append",
"highlightWord": true, "highlightWord": true,
"notificationType": "osd", "notificationType": "osd",
"autoUpdateNewCards": true, "autoUpdateNewCards": true
}, },
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, "highlightEnabled": false,
@@ -97,22 +100,22 @@
"decks": [], "decks": [],
"minSentenceWords": 3, "minSentenceWords": 3,
"nPlusOne": "#c6a0f6", "nPlusOne": "#c6a0f6",
"knownWord": "#a6da95", "knownWord": "#a6da95"
}, },
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)", "pattern": "[SubMiner] %f (%t)"
}, },
"isLapis": { "isLapis": {
"enabled": false, "enabled": false,
"sentenceCardModel": "Japanese sentences", "sentenceCardModel": "Japanese sentences",
"sentenceCardSentenceField": "Sentence", "sentenceCardSentenceField": "Sentence",
"sentenceCardAudioField": "SentenceAudio", "sentenceCardAudioField": "SentenceAudio"
}, },
"isKiku": { "isKiku": {
"enabled": false, "enabled": false,
"fieldGrouping": "disabled", "fieldGrouping": "disabled",
"deleteDuplicateInAuto": true, "deleteDuplicateInAuto": true
}, }
}, },
// ========================================== // ==========================================
@@ -120,6 +123,7 @@
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Fixed (non-configurable) overlay shortcuts: // Fixed (non-configurable) overlay shortcuts:
// - Ctrl/Cmd+A: append clipboard video path to MPV playlist // - Ctrl/Cmd+A: append clipboard video path to MPV playlist
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", "toggleVisibleOverlayGlobal": "Alt+Shift+O",
@@ -135,7 +139,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", "toggleSecondarySub": "CommandOrControl+Shift+V",
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J"
}, },
// ========================================== // ==========================================
@@ -145,19 +149,21 @@
// This edit-mode shortcut is fixed and is not currently configurable. // This edit-mode shortcut is fixed and is not currently configurable.
// ========================================== // ==========================================
"invisibleOverlay": { "invisibleOverlay": {
"startupVisibility": "platform-default", "startupVisibility": "platform-default"
}, },
// ========================================== // ==========================================
// Keybindings (MPV Commands) // Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults. // Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding. // Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"keybindings": [], "keybindings": [],
// ========================================== // ==========================================
// Subtitle Appearance // Subtitle Appearance
// Primary and secondary subtitle styling. // Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, "enableJlpt": false,
@@ -174,7 +180,7 @@
"N2": "#f5a97f", "N2": "#f5a97f",
"N3": "#f9e2af", "N3": "#f9e2af",
"N4": "#a6e3a1", "N4": "#a6e3a1",
"N5": "#8aadf4", "N5": "#8aadf4"
}, },
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, "enabled": false,
@@ -182,7 +188,13 @@
"topX": 1000, "topX": 1000,
"mode": "single", "mode": "single",
"singleColor": "#f5a97f", "singleColor": "#f5a97f",
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], "bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
]
}, },
"secondary": { "secondary": {
"fontSize": 24, "fontSize": 24,
@@ -190,19 +202,20 @@
"backgroundColor": "transparent", "backgroundColor": "transparent",
"fontWeight": "normal", "fontWeight": "normal",
"fontStyle": "normal", "fontStyle": "normal",
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif"
}, }
}, },
// ========================================== // ==========================================
// Secondary Subtitles // Secondary Subtitles
// Dual subtitle track options. // Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences. // Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ========================================== // ==========================================
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], "secondarySubLanguages": [],
"autoLoadSecondarySub": false, "autoLoadSecondarySub": false,
"defaultMode": "hover", "defaultMode": "hover"
}, },
// ========================================== // ==========================================
@@ -213,7 +226,7 @@
"defaultMode": "auto", "defaultMode": "auto",
"alass_path": "", "alass_path": "",
"ffsubsync_path": "", "ffsubsync_path": "",
"ffmpeg_path": "", "ffmpeg_path": ""
}, },
// ========================================== // ==========================================
@@ -221,7 +234,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10, "yPercent": 10
}, },
// ========================================== // ==========================================
@@ -231,7 +244,7 @@
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja", "languagePreference": "ja",
"maxEntryResults": 10, "maxEntryResults": 10
}, },
// ========================================== // ==========================================
@@ -242,7 +255,10 @@
"mode": "automatic", "mode": "automatic",
"whisperBin": "", "whisperBin": "",
"whisperModel": "", "whisperModel": "",
"primarySubLanguages": ["ja", "jpn"], "primarySubLanguages": [
"ja",
"jpn"
]
}, },
// ========================================== // ==========================================
@@ -251,7 +267,7 @@
// ========================================== // ==========================================
"anilist": { "anilist": {
"enabled": false, "enabled": false,
"accessToken": "", "accessToken": ""
}, },
// ========================================== // ==========================================
@@ -276,8 +292,16 @@
"pullPictures": false, "pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true, "directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": [
"transcodeVideoCodec": "h264", "mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
],
"transcodeVideoCodec": "h264"
}, },
// ========================================== // ==========================================
@@ -299,7 +323,7 @@
"telemetryDays": 30, "telemetryDays": 30,
"dailyRollupsDays": 365, "dailyRollupsDays": 365,
"monthlyRollupsDays": 1825, "monthlyRollupsDays": 1825,
"vacuumIntervalDays": 7, "vacuumIntervalDays": 7
}, }
}, }
} }

View File

@@ -11,6 +11,21 @@ You can use both together—install the plugin for on-demand control, but use `s
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`. `subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Commands ## Commands
```bash ```bash
@@ -46,6 +61,7 @@ subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directo
subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback
# Direct AppImage control # Direct AppImage control
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window) SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --stop # Stop overlay SubMiner.AppImage --stop # Stop overlay
@@ -73,6 +89,8 @@ SubMiner.AppImage --help # Show all options
- `--log-level` controls logger verbosity. - `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases. - `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
- Linux desktop launcher starts SubMiner with `--background` by default.
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`. - Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
### Launcher Subcommands ### Launcher Subcommands

View File

@@ -17,7 +17,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test:config:dist": "node --test dist/config/config.test.js", "test:config:dist": "node --test dist/config/config.test.js",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test": "bun run test:config && bun run test:core", "test": "bun run test:config && bun run test:core",
"test:config": "bun run build && bun run test:config:dist", "test:config": "bun run build && bun run test:config:dist",
@@ -77,7 +77,10 @@
"target": [ "target": [
"AppImage" "AppImage"
], ],
"category": "AudioVideo" "category": "AudioVideo",
"executableArgs": [
"--background"
]
}, },
"mac": { "mac": {
"target": [ "target": [

View File

@@ -258,6 +258,55 @@ test('parses jsonc and warns/falls back on invalid value', () => {
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port')); assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
}); });
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"logging": {
"level": "warn"
}
}`,
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'warn');
fs.writeFileSync(
configPath,
`{
"logging":`,
);
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSONC.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'warn');
});
test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'error');
fs.writeFileSync(configPath, '{"logging":');
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSON.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'error');
});
test('accepts valid logging.level', () => { test('accepts valid logging.level', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -715,11 +715,16 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{ {
title: 'AnkiConnect Integration', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: AI translation settings update live while SubMiner is running.',
'Most other AnkiConnect settings still require restart.',
],
key: 'ankiConnect', key: 'ankiConnect',
}, },
{ {
title: 'Keyboard Shortcuts', title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts', key: 'shortcuts',
}, },
{ {
@@ -737,11 +742,15 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'Extra keybindings that are merged with built-in defaults.', 'Extra keybindings that are merged with built-in defaults.',
'Set command to null to disable a default keybinding.', 'Set command to null to disable a default keybinding.',
], ],
notes: [
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
],
key: 'keybindings', key: 'keybindings',
}, },
{ {
title: 'Subtitle Appearance', title: 'Subtitle Appearance',
description: ['Primary and secondary subtitle styling.'], description: ['Primary and secondary subtitle styling.'],
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle', key: 'subtitleStyle',
}, },
{ {
@@ -750,6 +759,7 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'Dual subtitle track options.', 'Dual subtitle track options.',
'Used by subminer YouTube subtitle generation as secondary language preferences.', 'Used by subminer YouTube subtitle generation as secondary language preferences.',
], ],
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
key: 'secondarySub', key: 'secondarySub',
}, },
{ {

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { parse as parseJsonc } from 'jsonc-parser'; import { parse as parseJsonc, type ParseError } from 'jsonc-parser';
import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions'; import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
@@ -9,6 +9,19 @@ interface LoadResult {
path: string; path: string;
} }
export type ReloadConfigStrictResult =
| {
ok: true;
config: ResolvedConfig;
warnings: ConfigValidationWarning[];
path: string;
}
| {
ok: false;
error: string;
path: string;
};
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value); return value !== null && typeof value === 'object' && !Array.isArray(value);
} }
@@ -91,6 +104,26 @@ export class ConfigService {
return this.getConfig(); return this.getConfig();
} }
reloadConfigStrict(): ReloadConfigStrictResult {
const loadResult = this.loadRawConfigStrict();
if (!loadResult.ok) {
return loadResult;
}
const { config, path: configPath } = loadResult;
this.rawConfig = config;
this.configPathInUse = configPath;
const { resolved, warnings } = this.resolveConfig(config);
this.resolvedConfig = resolved;
this.warnings = warnings;
return {
ok: true,
config: this.getConfig(),
warnings: [...warnings],
path: configPath,
};
}
saveRawConfig(config: RawConfig): void { saveRawConfig(config: RawConfig): void {
if (!fs.existsSync(this.configDir)) { if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true }); fs.mkdirSync(this.configDir, { recursive: true });
@@ -112,6 +145,20 @@ export class ConfigService {
} }
private loadRawConfig(): LoadResult { private loadRawConfig(): LoadResult {
const strictResult = this.loadRawConfigStrict();
if (strictResult.ok) {
return strictResult;
}
return { config: {}, path: strictResult.path };
}
private loadRawConfigStrict():
| (LoadResult & { ok: true })
| {
ok: false;
error: string;
path: string;
} {
const configPath = fs.existsSync(this.configFileJsonc) const configPath = fs.existsSync(this.configFileJsonc)
? this.configFileJsonc ? this.configFileJsonc
: fs.existsSync(this.configFileJson) : fs.existsSync(this.configFileJson)
@@ -119,18 +166,29 @@ export class ConfigService {
: this.configFileJsonc; : this.configFileJsonc;
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
return { config: {}, path: configPath }; return { ok: true, config: {}, path: configPath };
} }
try { try {
const data = fs.readFileSync(configPath, 'utf-8'); const data = fs.readFileSync(configPath, 'utf-8');
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data); const parsed = configPath.endsWith('.jsonc')
? (() => {
const errors: ParseError[] = [];
const result = parseJsonc(data, errors);
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
return result;
})()
: JSON.parse(data);
return { return {
ok: true,
config: isObject(parsed) ? (parsed as Config) : {}, config: isObject(parsed) ? (parsed as Config) : {},
path: configPath, path: configPath,
}; };
} catch { } catch (error) {
return { config: {}, path: configPath }; const message = error instanceof Error ? error.message : 'Unknown parse error';
return { ok: false, error: message, path: configPath };
} }
} }

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
classifyConfigHotReloadDiff,
createConfigHotReloadRuntime,
type ConfigHotReloadRuntimeDeps,
} from './config-hot-reload';
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2;
next.websocket.port = prev.websocket.port + 1;
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
});
test('config hot reload runtime debounces rapid watch events', () => {
let watchedChangeCallback: (() => void) | null = null;
const pendingTimers = new Map<number, () => void>();
let nextTimerId = 1;
let reloadCalls = 0;
const deps: ConfigHotReloadRuntimeDeps = {
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => {
reloadCalls += 1;
return {
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [],
path: '/tmp/config.jsonc',
};
},
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
const id = nextTimerId;
nextTimerId += 1;
pendingTimers.set(id, callback);
return id as unknown as NodeJS.Timeout;
},
clearTimeout: (timeout) => {
pendingTimers.delete(timeout as unknown as number);
},
debounceMs: 25,
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
};
const runtime = createConfigHotReloadRuntime(deps);
runtime.start();
assert.equal(reloadCalls, 1);
if (!watchedChangeCallback) {
throw new Error('Expected watch callback to be registered.');
}
const trigger = watchedChangeCallback as () => void;
trigger();
trigger();
trigger();
assert.equal(pendingTimers.size, 1);
for (const callback of pendingTimers.values()) {
callback();
}
assert.equal(reloadCalls, 2);
});
test('config hot reload runtime reports invalid config and skips apply', () => {
const invalidMessages: string[] = [];
let watchedChangeCallback: (() => void) | null = null;
const runtime = createConfigHotReloadRuntime({
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => ({
ok: false,
error: 'Invalid JSON',
path: '/tmp/config.jsonc',
}),
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
callback();
return 1 as unknown as NodeJS.Timeout;
},
clearTimeout: () => {},
debounceMs: 0,
onHotReloadApplied: () => {
throw new Error('Hot reload should not apply for invalid config.');
},
onRestartRequired: () => {
throw new Error('Restart warning should not trigger for invalid config.');
},
onInvalidConfig: (message) => {
invalidMessages.push(message);
},
});
runtime.start();
assert.equal(watchedChangeCallback, null);
assert.equal(invalidMessages.length, 1);
});

View File

@@ -0,0 +1,159 @@
import { type ReloadConfigStrictResult } from '../../config';
import type { ResolvedConfig } from '../../types';
export interface ConfigHotReloadDiff {
hotReloadFields: string[];
restartRequiredFields: string[];
}
export interface ConfigHotReloadRuntimeDeps {
getCurrentConfig: () => ResolvedConfig;
reloadConfigStrict: () => ReloadConfigStrictResult;
watchConfigPath: (configPath: string, onChange: () => void) => { close: () => void };
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
debounceMs?: number;
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
}
export interface ConfigHotReloadRuntime {
start: () => void;
stop: () => void;
}
function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = [];
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
hotReloadFields.push('subtitleStyle');
}
if (!isEqual(prev.keybindings, next.keybindings)) {
hotReloadFields.push('keybindings');
}
if (!isEqual(prev.shortcuts, next.shortcuts)) {
hotReloadFields.push('shortcuts');
}
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
hotReloadFields.push('secondarySub.defaultMode');
}
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
hotReloadFields.push('ankiConnect.ai');
}
const keys = new Set([
...(Object.keys(prev) as Array<keyof ResolvedConfig>),
...(Object.keys(next) as Array<keyof ResolvedConfig>),
]);
for (const key of keys) {
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') {
continue;
}
if (key === 'secondarySub') {
const normalizedPrev = {
...prev.secondarySub,
defaultMode: next.secondarySub.defaultMode,
};
if (!isEqual(normalizedPrev, next.secondarySub)) {
restartRequiredFields.push('secondarySub');
}
continue;
}
if (key === 'ankiConnect') {
const normalizedPrev = {
...prev.ankiConnect,
ai: next.ankiConnect.ai,
};
if (!isEqual(normalizedPrev, next.ankiConnect)) {
restartRequiredFields.push('ankiConnect');
}
continue;
}
if (!isEqual(prev[key], next[key])) {
restartRequiredFields.push(String(key));
}
}
return { hotReloadFields, restartRequiredFields };
}
export function createConfigHotReloadRuntime(
deps: ConfigHotReloadRuntimeDeps,
): ConfigHotReloadRuntime {
let watcher: { close: () => void } | null = null;
let timer: NodeJS.Timeout | null = null;
let watchedPath: string | null = null;
const debounceMs = deps.debounceMs ?? 250;
const reloadWithDiff = () => {
const prev = deps.getCurrentConfig();
const result = deps.reloadConfigStrict();
if (!result.ok) {
deps.onInvalidConfig(`Config reload failed: ${result.error}`);
return;
}
if (watchedPath !== result.path) {
watchPath(result.path);
}
const diff = classifyDiff(prev, result.config);
if (diff.hotReloadFields.length > 0) {
deps.onHotReloadApplied(diff, result.config);
}
if (diff.restartRequiredFields.length > 0) {
deps.onRestartRequired(diff.restartRequiredFields);
}
};
const scheduleReload = () => {
if (timer) {
deps.clearTimeout(timer);
}
timer = deps.setTimeout(() => {
timer = null;
reloadWithDiff();
}, debounceMs);
};
const watchPath = (configPath: string) => {
watcher?.close();
watcher = deps.watchConfigPath(configPath, scheduleReload);
watchedPath = configPath;
};
return {
start: () => {
if (watcher) {
return;
}
const result = deps.reloadConfigStrict();
if (!result.ok) {
deps.onInvalidConfig(`Config watcher startup failed: ${result.error}`);
return;
}
watchPath(result.path);
},
stop: () => {
if (timer) {
deps.clearTimeout(timer);
timer = null;
}
watcher?.close();
watcher = null;
watchedPath = null;
},
};
}
export { classifyDiff as classifyConfigHotReloadDiff };

View File

@@ -108,3 +108,4 @@ export {
createOverlayManager, createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
} from './overlay-manager'; } from './overlay-manager';
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';

View File

@@ -23,6 +23,9 @@ import {
shell, shell,
protocol, protocol,
Extension, Extension,
Menu,
Tray,
nativeImage,
} from 'electron'; } from 'electron';
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
@@ -57,6 +60,7 @@ import type {
RuntimeOptionState, RuntimeOptionState,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
ResolvedConfig, ResolvedConfig,
ConfigHotReloadPayload,
} from './types'; } from './types';
import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { AnkiIntegration } from './anki-integration'; import { AnkiIntegration } from './anki-integration';
@@ -119,6 +123,7 @@ import {
runStartupBootstrapRuntime, runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
authenticateWithPasswordRuntime, authenticateWithPasswordRuntime,
createConfigHotReloadRuntime,
resolveJellyfinPlaybackPlanRuntime, resolveJellyfinPlaybackPlanRuntime,
jellyfinTicksToSecondsRuntime, jellyfinTicksToSecondsRuntime,
sendMpvCommandRuntime, sendMpvCommandRuntime,
@@ -194,6 +199,7 @@ const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json'; const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json';
const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json'; const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json';
const TRAY_TOOLTIP = 'SubMiner';
let anilistCurrentMediaKey: string | null = null; let anilistCurrentMediaKey: string | null = null;
let anilistCurrentMediaDurationSec: number | null = null; let anilistCurrentMediaDurationSec: number | null = null;
@@ -357,6 +363,7 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(), mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT, texthookerPort: DEFAULT_TEXTHOOKER_PORT,
}); });
let appTray: Tray | null = null;
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered, getShortcutsRegistered: () => appState.shortcutsRegistered,
@@ -396,6 +403,64 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
}, },
}); });
const configHotReloadRuntime = createConfigHotReloadRuntime({
getCurrentConfig: () => getResolvedConfig(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => {
const watchTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath);
const watcher = fs.watch(watchTarget, (_eventType, filename) => {
if (watchTarget === configPath) {
onChange();
return;
}
const normalized =
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
onChange();
}
});
return {
close: () => {
watcher.close();
},
};
},
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: (diff, config) => {
const payload = buildConfigHotReloadPayload(config);
appState.keybindings = payload.keybindings;
if (diff.hotReloadFields.includes('shortcuts')) {
refreshGlobalAndOverlayShortcuts();
}
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
appState.secondarySubMode = payload.secondarySubMode;
broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
if (diff.hotReloadFields.includes('ankiConnect.ai') && appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch({ ai: config.ankiConnect.ai });
}
if (diff.hotReloadFields.length > 0) {
broadcastToOverlayWindows('config:hot-reload', payload);
}
},
onRestartRequired: (fields) => {
const message = `Config updated; restart required for: ${fields.join(', ')}`;
showMpvOsd(message);
showDesktopNotification('SubMiner', { body: message });
},
onInvalidConfig: (message) => {
showMpvOsd(message);
showDesktopNotification('SubMiner', { body: message });
},
});
const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({ const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({
isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt,
getSearchPaths: () => getSearchPaths: () =>
@@ -590,6 +655,28 @@ function openRuntimeOptionsPalette(): void {
function getResolvedConfig() { function getResolvedConfig() {
return configService.getConfig(); return configService.getConfig();
} }
function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
if (!config.subtitleStyle) {
return null;
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};
}
function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
secondarySubMode: config.secondarySub.defaultMode,
};
}
function getResolvedJellyfinConfig() { function getResolvedJellyfinConfig() {
return getResolvedConfig().jellyfin; return getResolvedConfig().jellyfin;
} }
@@ -2084,6 +2171,7 @@ const startupState = runStartupBootstrapRuntime(
reloadConfig: () => { reloadConfig: () => {
configService.reloadConfig(); configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
configHotReloadRuntime.start();
void refreshAnilistClientSecretState({ force: true }); void refreshAnilistClientSecretState({ force: true });
}, },
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
@@ -2172,11 +2260,13 @@ const startupState = runStartupBootstrapRuntime(
}, },
texthookerOnlyMode: appState.texthookerOnlyMode, texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () => shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfig(), appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), handleInitialArgs: () => handleInitialArgs(),
}), }),
onWillQuitCleanup: () => { onWillQuitCleanup: () => {
destroyTray();
configHotReloadRuntime.stop();
restorePreviousSecondarySubVisibility(); restorePreviousSecondarySubVisibility();
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
subtitleWsService.stop(); subtitleWsService.stop();
@@ -2224,6 +2314,7 @@ const startupState = runStartupBootstrapRuntime(
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); overlayVisibilityRuntime.updateInvisibleOverlayVisibility();
}, },
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
}), }),
}), }),
); );
@@ -2296,6 +2387,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
function handleInitialArgs(): void { function handleInitialArgs(): void {
if (!appState.initialArgs) return; if (!appState.initialArgs) return;
if (appState.backgroundMode) {
ensureTray();
}
if ( if (
!appState.texthookerOnlyMode && !appState.texthookerOnlyMode &&
appState.immersionTracker && appState.immersionTracker &&
@@ -2529,6 +2623,103 @@ function createInvisibleWindow(): BrowserWindow {
return window; return window;
} }
function resolveTrayIconPath(): string | null {
const candidates = [
path.join(process.resourcesPath, 'assets', 'SubMiner.png'),
path.join(app.getAppPath(), 'assets', 'SubMiner.png'),
path.join(__dirname, '..', 'assets', 'SubMiner.png'),
path.join(__dirname, '..', '..', 'assets', 'SubMiner.png'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: 'Open Overlay',
click: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
setVisibleOverlayVisible(true);
},
},
{
label: 'Open Yomitan Settings',
click: () => {
openYomitanSettings();
},
},
{
label: 'Open Runtime Options',
click: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
openRuntimeOptionsPalette();
},
},
{
label: 'Configure Jellyfin',
click: () => {
openJellyfinSetupWindow();
},
},
{
label: 'Configure AniList',
click: () => {
openAnilistSetupWindow();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.quit();
},
},
]);
}
function ensureTray(): void {
if (appTray) {
appTray.setContextMenu(buildTrayMenu());
return;
}
const iconPath = resolveTrayIconPath();
let trayIcon = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
if (trayIcon.isEmpty()) {
logger.warn('Tray icon asset not found; using empty icon placeholder.');
}
if (process.platform === 'linux' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 20, height: 20 });
}
appTray = new Tray(trayIcon);
appTray.setToolTip(TRAY_TOOLTIP);
appTray.setContextMenu(buildTrayMenu());
appTray.on('click', () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
setVisibleOverlayVisible(true);
});
}
function destroyTray(): void {
if (!appTray) {
return;
}
appTray.destroy();
appTray = null;
}
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
if (appState.overlayRuntimeInitialized) { if (appState.overlayRuntimeInitialized) {
return; return;
@@ -2600,6 +2791,12 @@ function registerGlobalShortcuts(): void {
}); });
} }
function refreshGlobalAndOverlayShortcuts(): void {
globalShortcut.unregisterAll();
registerGlobalShortcuts();
syncOverlayShortcuts();
}
function getConfiguredShortcuts() { function getConfiguredShortcuts() {
return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG);
} }
@@ -2916,17 +3113,7 @@ registerIpcRuntimeServices({
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => { getSubtitleStyle: () => {
const resolvedConfig = getResolvedConfig(); const resolvedConfig = getResolvedConfig();
if (!resolvedConfig.subtitleStyle) { return resolveSubtitleStyleForRenderer(resolvedConfig);
return null;
}
return {
...resolvedConfig.subtitleStyle,
nPlusOneColor: resolvedConfig.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: resolvedConfig.ankiConnect.nPlusOne.knownWord,
enableJlpt: resolvedConfig.subtitleStyle.enableJlpt,
frequencyDictionary: resolvedConfig.subtitleStyle.frequencyDictionary,
};
}, },
saveSubtitlePosition: (position: unknown) => saveSubtitlePosition(position as SubtitlePosition), saveSubtitlePosition: (position: unknown) => saveSubtitlePosition(position as SubtitlePosition),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,

View File

@@ -48,6 +48,7 @@ import type {
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
OverlayContentMeasurement, OverlayContentMeasurement,
ShortcutsConfig, ShortcutsConfig,
ConfigHotReloadPayload,
} from './types'; } from './types';
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
@@ -236,6 +237,14 @@ const electronAPI: ElectronAPI = {
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send('overlay-content-bounds:report', measurement); ipcRenderer.send('overlay-content-bounds:report', measurement);
}, },
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on(
'config:hot-reload',
(_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => {
callback(payload);
},
);
},
}; };
contextBridge.exposeInMainWorld('electronAPI', electronAPI); contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -185,13 +185,7 @@ export function createKeyboardHandlers(
} }
async function setupMpvInputForwarding(): Promise<void> { async function setupMpvInputForwarding(): Promise<void> {
const keybindings: Keybinding[] = await window.electronAPI.getKeybindings(); updateKeybindings(await window.electronAPI.getKeybindings());
ctx.state.keybindingsMap = new Map();
for (const binding of keybindings) {
if (binding.command) {
ctx.state.keybindingsMap.set(binding.key, binding.command);
}
}
document.addEventListener('keydown', (e: KeyboardEvent) => { document.addEventListener('keydown', (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
@@ -293,7 +287,17 @@ export function createKeyboardHandlers(
}); });
} }
function updateKeybindings(keybindings: Keybinding[]): void {
ctx.state.keybindingsMap = new Map();
for (const binding of keybindings) {
if (binding.command) {
ctx.state.keybindingsMap.set(binding.key, binding.command);
}
}
}
return { return {
setupMpvInputForwarding, setupMpvInputForwarding,
updateKeybindings,
}; };
} }

View File

@@ -24,6 +24,7 @@ import type {
SubtitleData, SubtitleData,
SubtitlePosition, SubtitlePosition,
SubsyncManualPayload, SubsyncManualPayload,
ConfigHotReloadPayload,
} from '../types'; } from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js'; import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createMouseHandlers } from './handlers/mouse.js'; import { createMouseHandlers } from './handlers/mouse.js';
@@ -196,6 +197,12 @@ async function init(): Promise<void> {
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runtimeOptionsModal.updateRuntimeOptions(options); runtimeOptionsModal.updateRuntimeOptions(options);
}); });
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
keyboardHandlers.updateKeybindings(payload.keybindings);
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
measurementReporter.schedule();
});
window.electronAPI.onOpenRuntimeOptions(() => { window.electronAPI.onOpenRuntimeOptions(() => {
runtimeOptionsModal.openRuntimeOptionsModal().catch(() => { runtimeOptionsModal.openRuntimeOptionsModal().catch(() => {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);

View File

@@ -709,6 +709,12 @@ export type JimakuDownloadResult =
| { ok: true; path: string } | { ok: true; path: string }
| { ok: false; error: JimakuApiError }; | { ok: false; error: JimakuApiError };
export interface ConfigHotReloadPayload {
keybindings: Keybinding[];
subtitleStyle: SubtitleStyleConfig | null;
secondarySubMode: SecondarySubMode;
}
export interface ElectronAPI { export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | null; getOverlayLayer: () => 'visible' | 'invisible' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void; onSubtitle: (callback: (data: SubtitleData) => void) => void;
@@ -763,6 +769,7 @@ export interface ElectronAPI {
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
} }
declare global { declare global {