Compare commits

...

28 Commits

Author SHA1 Message Date
sudacode fcd6511aa1 fix: default hoverTokenBackgroundColor to transparent
- Change default from rgba(54, 58, 79, 0.84) to transparent in config, CSS, and sanitizer fallbacks
- Update tests and docs to reflect new default
2026-05-20 16:31:59 -07:00
sudacode e18b6eda77 fix: reset WS subtitle state and parsed cues on media path change
- Clear activeParsedSubtitleCues, activeParsedSubtitleSource, lastObservedTimePos
- Broadcast reset payload to subtitleWsService and annotationSubtitleWsService
- Extend wiring test to cover new reset fields and WS broadcasts
2026-05-20 11:40:21 -07:00
sudacode 1145e131da fix: clear stale CSS properties and subtitle state on style/media update
- Remove CSS properties absent from subsequent subtitle style updates
- Broadcast subtitle:set clear when media path changes
- Preserve launcher lifecycle ownership for already-managed overlay apps
- Clamp negative autoplay current time to zero
- Reject blank subminerBinaryPath values via parseNonEmptyString
- Log and rethrow legacy config migration errors instead of swallowing
- Normalize modifier aliases (e.g. CommandOrControl) in keybinding display
2026-05-20 10:14:28 -07:00
sudacode dde19ad0da fix: prime startup subtitle before autoplay resumes
- Add `selectAutoplayStartupCue` to pick active or imminent cue at startup
- Call `primeCurrentSubtitle` in warm-release before signaling autoplay ready
- Reset primed state on media path change to avoid stale cue leaks
2026-05-20 08:09:49 -07:00
sudacode 4813ce1fea fix: drop stale deferred autoplay-ready signals on media change
- autoplay-ready gate now stamps pending signal with mediaPath and discards it on flush if media has changed
- tokenization warm release skips signaling when current media path is cleared (null)
- tighten regex matchers in main-wiring and overlay-legacy-cleanup tests
2026-05-20 01:45:14 -07:00
sudacode 403ee32579 fix: managed playback overlay lifecycle for launcher-owned sessions
- Remove --background from launcher-owned mpv starts; quit only non-tray/non-background managed sessions
- Defer autoplay-ready signal until overlay window content is loaded; retry after flush
- Retry socket availability before auto-starting overlay (up to 25 attempts, 200ms apart)
- Extract warm tokenization signal into autoplay-tokenization-warm-release with stale-media guard
- Queue second-instance commands until app ready runtime completes
- Guard globalShortcut cleanup with isAppReady check to avoid pre-ready crash
- Recognize "osx" as a macOS platform alias in Lua environment detection
2026-05-20 01:45:14 -07:00
sudacode e4165a418c refactor: deduplicate mpv plugin config, fix CSS font fallbacks
- Extract shared getMpvPluginRuntimeConfig() helper to eliminate duplicate inline objects
- Call ensureImmersionTrackerStarted() before markActiveVideoWatched action
- Quote multi-word font names and add sans-serif generic fallback in subtitle sidebar CSS
- Add main-wiring tests asserting deduplication and tracker start ordering
2026-05-20 01:45:14 -07:00
sudacode 2772c61aba feat: add mark-watched action, background app reuse, and N+1 compat
- Add `--mark-watched` CLI flag + mpv session binding; marks video watched, shows OSD, advances playlist
- Launcher detects running background app via `--app-ping` and borrows it instead of owning its lifecycle
- Preserve N+1 highlighting for existing configs with `knownWords.highlightEnabled` set
- Fix `resolveConfiguredShortcuts` to respect explicit `null` overrides (disabling defaults)
- Split session-help modal into focused modules (colors, render, sections, tabs)
2026-05-20 01:45:14 -07:00
sudacode 5c710ffcaf test: add 20s timeout to 365d trends dashboard test 2026-05-20 01:45:14 -07:00
sudacode ab29d56649 feat: include unconfigured secret paths in config settings snapshot
- Export SECRET_PATHS from registry for reuse
- Populate snapshot with `{ configured: true }` for non-empty secrets not already covered by registered fields
2026-05-20 01:45:14 -07:00
sudacode 1f7318d615 feat: expand hot-reload to logging, jimaku, subsync, and Anki sub-fields
- Mark logging.level, stats keys, jimaku.*, subsync.*, and granular ankiConnect fields (knownWords, nPlusOne, fields, isLapis, isKiku, behavior) as hot-reloadable
- Refactor classifyConfigHotReloadDiff to path-walk diffing instead of per-key branches
- Wire setLogLevel, invalidateTokenizationCache, refreshSubtitlePrefetch, refreshCurrentSubtitle into hot-reload applied handler
- Exclude ai.* and ankiConnect.ai.* prefixes from config window; hide fields.translation
- Update docs and config.example.jsonc hot-reload annotations
2026-05-20 01:45:14 -07:00
sudacode cc7c3939e9 docs: add config subcommand and --config flag to CLI reference 2026-05-20 01:45:14 -07:00
sudacode 887de056c5 docs: simplify and restructure installation guide
- Consolidate requirements into a single table with status column
- Rewrite installation.md as a numbered 3-step guide
- Remove verbose platform-specific notes; fold essentials into platform sections
- Trim README quick-start to minimal install/launch commands
2026-05-20 01:45:14 -07:00
sudacode 553117356d fix: disable macOS mpv menu shortcuts, buffer latest subtitle IPC state
- Pass --macos-menu-shortcuts=no on Darwin so SubMiner bindings reach mpv
- Replace queued IPC listener with latest-value variant for subtitle channels
- Skip JSONC line/block comments in duplicate-key offset helpers
- Preserve configured Anki note model name in selectPreferredNoteFieldModelName
- Guard known-words deck rename against collision; add chooseKnownWordsDeckRenameValue
- Apply asCssColor on hover token CSS compat reads
2026-05-20 01:45:14 -07:00
sudacode 193b3136f2 migrate subtitle style config to CSS declaration shape
- Flat style keys (fontFamily, fontSize, hoverTokenColor, etc.) consolidated into subtitleStyle.css, secondary.css, and subtitleSidebar.css objects
- Hover token colors migrated to --subtitle-hover-token-color CSS custom properties
- Plugin app-ping now checks result.status (0=running, 1=stopped) to avoid treating transient failures as stopped
- Note-fields note type picker defaults to configured deck's note type before falling back to Kiku/Lapis
- New migration for legacy ankiConnect N+1 config paths
2026-05-20 01:45:14 -07:00
sudacode 1bb7b26641 fix: transport AppImage args via env and gate restart on app-ping
- Transport Linux AppImage CLI args through SUBMINER_APP_ARGC/ARG_* env vars instead of argv
- Add --app-ping command to probe single-instance lock ownership (exit 0 = running, 1 = not)
- Gate manual restart: poll app-ping until old app releases lock, then until new app owns it
- Preserve user-paused playback when disarming the auto-play-ready gate on restart
- Snapshot subtitles before connection side effects (sub-visibility hide) can suppress them
- Reapply overlay bounds after first show for Hyprland compatibility
2026-05-20 01:45:14 -07:00
sudacode 48447c2f1a fix: curl fetch for Linux updater, overlay restart restore, Yomitan late
- Use /usr/bin/curl on Linux for update checks to avoid Electron net-service crashes
- Restore visible overlay on manual restart even when auto-start visibility is disabled
- Reload overlay windows after Yomitan extension loads to fix popup race on startup
2026-05-20 01:45:14 -07:00
sudacode 2b13c82d69 feat(settings): move restart badge inline with option title
- Remove field-meta row (config path, advanced chip) from option rows
- Inline live/restart status badge beside each option label
- Extract getFieldTitleBadges into settings-field-layout module with tests
2026-05-20 01:43:20 -07:00
sudacode db60365b0e fix: normalize anki deck sample size 2026-05-20 01:43:20 -07:00
sudacode 93d9ed81a2 fix: address follow-up review feedback 2026-05-20 01:43:20 -07:00
sudacode 6f48d4b65b fix: address config modal review feedback 2026-05-20 01:43:20 -07:00
sudacode 7fb1e6d7a5 fix: add missing changelog metadata 2026-05-20 01:43:20 -07:00
sudacode 1ff44e0d69 feat(config): unify mpv plugin options under main config and add CSS/Ani
- Replace subminer.conf plugin config with mpv.* fields in config.jsonc
- Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config
- Add subtitleSidebar.css field; migrate legacy sidebar appearance fields
- Add paintOrder and WebkitTextStroke to subtitle style options
- Update default subtitle/sidebar fontFamily to CJK-first stack
- Fix overlay visible state surviving mpv y-r restart
- Fix live config saves applying subtitle CSS immediately to open overlays
- Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load
- Switch AniSkip button key setting to click-to-learn key capture
2026-05-20 01:43:20 -07:00
sudacode 0354a0e74b feat(config): add subtitle CSS editor, nPlusOne.enabled flag, and fix se
- subtitleStyle.css / subtitleStyle.secondary.css replace flat style fields in the settings window
- ankiConnect.nPlusOne.enabled gates known-word cache independently of knownWords.highlightEnabled
- Settings search now covers all categories, narrows on multi-word terms, and hides editor-owned fields
- Default note-type picker to Kiku then Lapis; rename isLapis.sentenceCardModel default to "Lapis"
2026-05-20 01:43:20 -07:00
sudacode b0fd7bd9e8 fix(launcher): suppress Electron menu diagnostics 2026-05-20 01:43:20 -07:00
sudacode 58f5fff6ad style: format config settings changes 2026-05-20 01:43:20 -07:00
sudacode 309ce6ef8f feat(config): reorganize settings window and move annotation colors to subtitleStyle
- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
2026-05-20 01:43:20 -07:00
sudacode a54f03f0cd Fix Jellyfin Login (#76) 2026-05-20 00:46:11 -07:00
246 changed files with 12838 additions and 2622 deletions
+37 -87
View File
@@ -4,7 +4,7 @@
# SubMiner
Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv.
Integrates Yomitan with mpv - look up words, mine to Anki, and track your immersion without leaving the player.
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -110,65 +110,35 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
| | Required | Recommended | Optional |
| -------------- | --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — | — |
| **Processing** | — | `ffmpeg` (audio clips & screenshots) | `mecab` + `mecab-ipadic` (annotation POS filtering), `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
| **Media** | — | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
| **Selection** | — | — | `fzf` / `rofi` |
Only **mpv** is required. Everything else is optional but enhances the experience.
> [!TIP]
> `ffmpeg` is not strictly required to run SubMiner, but without it audio clips and screenshots will not be attached to Anki cards. Most users will want it installed.
> [!NOTE]
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
**Platform-specific:**
| Linux | macOS | Windows |
| ------------------------------------------------------------ | ------------------------ | ------------- |
| Hyprland (`hyprctl`) · X11/Xwayland (`xdotool` + `xwininfo`) | Accessibility permission | No extra deps |
> [!NOTE]
> **Wayland support is compositor-specific.** Wayland has no universal API for window positioning and each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Hyprland is the only native Wayland backend supported currenlty. All other Linux compositors require both mpv and SubMiner to run under X11 or Xwayland. The launcher detects your compositor and configures this automatically.
| Dependency | Status | What it does |
| -------------------- | ----------- | ------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise N+1, JLPT, and frequency annotations |
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
<details>
<summary><b>Arch Linux</b></summary>
<summary><b>Platform-specific install commands</b></summary>
**Arch Linux:**
```bash
paru -S --needed mpv ffmpeg
# Optional
paru -S --needed mecab-git mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer xdotool xorg-xwininfo
# Optional: subtitle sync (install at least one for subtitle syncing to work)
paru -S --needed alass python-ffsubsync
# X11 / Xwayland (required for non-Hyprland compositors)
paru -S --needed xdotool xorg-xwininfo
sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
```
</details>
<details>
<summary><b>macOS</b></summary>
**macOS:**
```bash
brew install mpv ffmpeg
# Optional
brew install mecab mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer
# Optional: subtitle sync (install at least one for subtitle syncing to work)
brew install alass
pip install ffsubsync
brew install mpv ffmpeg mecab mecab-ipadic
```
Grant Accessibility permission to SubMiner in **System Settings > Privacy & Security > Accessibility**.
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
</details>
<details>
<summary><b>Windows</b></summary>
Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on your `PATH`.
Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary for additional metadata enrichment.
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
</details>
@@ -176,7 +146,7 @@ Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download
## Quick Start
### 1. Install
### 1. Install SubMiner
<details>
<summary><b>Arch Linux (AUR)</b></summary>
@@ -185,12 +155,6 @@ Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download
paru -S subminer-bin
```
Or manually:
```bash
git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makepkg -si
```
</details>
<details>
@@ -199,40 +163,24 @@ git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makep
```bash
mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage
&& chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
</details>
<details>
<summary><b>macOS</b></summary>
<summary><b>macOS (DMG)</b></summary>
Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
Also download the `subminer` launcher (recommended):
```bash
mkdir -p ~/.local/bin
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
</details>
<details>
<summary><b>Windows</b></summary>
Download the latest installer (`.exe`) [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
**Note:** On Windows the recommended way to run playback is with the **SubMiner mpv** shortcut created during first-run setup. First-run setup can also optionally install Bun and a `subminer.cmd` command shim to your user PATH, so new terminals can run `subminer` without adding `SubMiner.exe` to PATH.
Download and run the latest installer (`.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
</details>
@@ -243,28 +191,30 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
</details>
### 2. First Launch
### 2. Launch & Set Up
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
```bash
subminer app --setup # launch the first-run setup wizard
# Linux (AUR)
subminer app --setup
# macOS — open SubMiner.app, or:
subminer app --setup
```
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing Yomitan dictionaries. The setup popup can also optionally install Bun and the `subminer` command-line launcher; those choices do not block setup completion.
On **Windows**, just run `SubMiner.exe` setup opens automatically on first launch.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
### 3. Mine
### 3. Play
```bash
subminer video.mkv # play video with overlay
subminer --start video.mkv # explicit overlay start
subminer stats # open immersion dashboard
subminer stats -b # stats daemon in background
subminer stats -s # stop background stats daemon
subminer config # open configuration window
subminer --config # open configuration window via flag
```
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it to open mpv, or drag a video file onto it. You can also run `SubMiner.exe --launch-mpv` from a terminal.
On **Windows**, use the **SubMiner mpv** shortcut created during setup — double-click it or drag a video file onto it.
## Documentation
-11
View File
@@ -15,7 +15,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0",
},
"devDependencies": {
@@ -189,8 +188,6 @@
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
@@ -725,14 +722,6 @@
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed the Jellyfin setup popup login path on Windows by using an IPC bridge, showing immediate login progress, and timing out unreachable server login attempts with an inline error.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Reused an already-running background SubMiner app for launcher-opened videos, preserving warmups and keeping the tray app alive after playback closes.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Updated the generated example config to use the same CSS declaration paths written by the Settings window for subtitle and sidebar appearance.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Reorganized the Configuration window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: settings
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
+2
View File
@@ -4,3 +4,5 @@ area: config
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling.
- Marked safe live config options in the Configuration window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
- Hid AI and translation fields from the Configuration window while keeping them supported in config files.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Primed the first startup subtitle before autoplay resumes so the overlay can render text before video playback begins.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: shortcuts
- Disabled native mpv menu shortcuts during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus.
@@ -0,0 +1,4 @@
type: fixed
area: playback
- Fixed managed mpv startup so launcher-owned videos quit SubMiner when playback ends, background/tray sessions stay alive, and pause-until-ready waits for the overlay and tokenization readiness before playback resumes.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Moved known-word and N+1 annotation colors to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; legacy Anki color keys are still accepted with warnings.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Fixed Linux automatic update checks to avoid Electron networking, preventing native Electron network-service crashes during video startup.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Managed bundled mpv plugin startup options from SubMiner config.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Wired configured session shortcuts, including `stats.markWatchedKey`, through mpv so custom add/remove changes work while mpv has focus.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Defaulted the note-fields note type picker to the configured Anki deck's note type when available, then exact `Kiku`, then exact `Lapis`, otherwise leaving it blank for manual selection.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Preserved N+1 subtitle highlighting for existing configs that already enabled known-word highlighting, while keeping N+1 disabled by default for new configs unless `ankiConnect.nPlusOne.enabled` is set.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the visible overlay and subtitle stream alive after restarting SubMiner from the mpv `y-r` shortcut by transporting Linux AppImage control args safely, restoring mpv subtitle visibility during shutdown, snapshotting subtitles before overlay suppression resumes, reapplying Linux overlay bounds after the restarted window maps, allowing Hyprland to resize the visible overlay window, and preserving user-paused playback while readiness gates clear.
@@ -0,0 +1,4 @@
type: changed
area: jellyfin
- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field.
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Migrated legacy subtitle hover token colors into `subtitleStyle.css` instead of leaving `hoverTokenColor` or `hoverTokenBackgroundColor` behind.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Added `subtitleSidebar.css`, migrated legacy sidebar appearance fields into it, and updated subtitle font defaults to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: windows
- Windows startup failures now show a native error dialog and write fatal details to the SubMiner app log instead of exiting silently.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updates
- Windows automatic updates now keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch without requiring `curl.exe`.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
+72 -53
View File
@@ -7,10 +7,11 @@
{
// ==========================================
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
// Visible Overlay Auto-Start
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
// SubMiner can still auto-start in the background when this is false.
// ==========================================
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
// ==========================================
// Texthooker Server
@@ -45,6 +46,7 @@
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running.
// ==========================================
"logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
@@ -336,6 +338,7 @@
// ==========================================
// Auto Subtitle Sync
// Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run.
// ==========================================
"subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
@@ -360,29 +363,31 @@
// ==========================================
"subtitleStyle": {
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "35px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"fontWeight": "600", // Font weight setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"fontStyle": "normal", // Font style setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"jlptColors": {
"N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting.
@@ -406,19 +411,21 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
"fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "600", // Font weight setting.
"fontStyle": "normal" // Font style setting.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "24px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
@@ -434,16 +441,18 @@
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
"opacity": 0.95, // Base opacity applied to the sidebar shell.
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
"font-size": "16px", // Font size setting.
"opacity": "0.95", // Opacity setting.
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width 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-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
@@ -463,7 +472,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -512,8 +521,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
"color": "#a6da95" // Color used for known-word highlights.
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -524,15 +532,15 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
"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).
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
@@ -544,6 +552,7 @@
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
// Hot-reload: Jimaku changes apply to the next Jimaku request.
// ==========================================
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
@@ -554,6 +563,7 @@
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
@@ -598,14 +608,23 @@
// ==========================================
// MPV Launcher
// Optional mpv.exe override for Windows playback entry points.
// SubMiner-managed mpv launch and bundled plugin options.
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ==========================================
"mpv": {
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
}, // Optional mpv.exe override for Windows playback entry points.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. 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.
"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.
}, // SubMiner-managed mpv launch and bundled plugin options.
// ==========================================
// Jellyfin
@@ -648,7 +667,7 @@
// ==========================================
"discordPresence": {
"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".
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
+86 -52
View File
@@ -8,10 +8,6 @@ outline: [2, 3]
import { withBase } from 'vitepress';
</script>
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
## Quick Start
For most users, start with this minimal configuration:
@@ -39,9 +35,38 @@ For most users, start with this minimal configuration:
Then customize as needed using the sections below.
## Settings
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
- Appearance
- Behavior
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
## Configuration File
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
Generate a fresh default config from the centralized config registry:
@@ -63,28 +88,6 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
### Configuration Window
SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape:
- Viewing
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Viewing** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`.
The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, the removed YouTube subtitle-generation primary-language key, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, and normal editing for `controller.buttonIndices`. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
### Hot-Reload Behavior
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
@@ -96,7 +99,28 @@ Hot-reloadable fields:
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
- `stats.toggleKey`
- `stats.markWatchedKey`
- `logging.level`
- `youtube.primarySubLanguages`
- `jimaku.*`
- `subsync.*`
- `ankiConnect.ai.enabled`
- `ankiConnect.behavior.autoUpdateNewCards`
- `ankiConnect.knownWords.highlightEnabled`
- `ankiConnect.knownWords.refreshMinutes`
- `ankiConnect.knownWords.addMinedWordsImmediately`
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.knownWords.decks`
- `ankiConnect.nPlusOne.enabled`
- `ankiConnect.nPlusOne.minSentenceWords`
- `ankiConnect.fields.word`
- `ankiConnect.fields.audio`
- `ankiConnect.fields.image`
- `ankiConnect.fields.sentence`
- `ankiConnect.fields.miscInfo`
- `ankiConnect.isLapis.sentenceCardModel`
- `ankiConnect.isKiku.fieldGrouping`
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -104,6 +128,7 @@ Restart-required changes:
- Any other config sections still require restart.
- Shared top-level `ai` provider settings still require restart.
- AnkiConnect transport/proxy/media/deck/tag fields still require restart unless listed above.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview
@@ -323,25 +348,29 @@ See `config.example.jsonc` for detailed configuration options.
```json
{
"subtitleStyle": {
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"fontSize": 35,
"fontColor": "#cad3f5",
"fontWeight": "600",
"lineHeight": 1.35,
"letterSpacing": "-0.01em",
"wordSpacing": 0,
"fontKerning": "normal",
"textRendering": "geometricPrecision",
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"fontStyle": "normal",
"backgroundColor": "transparent",
"backdropFilter": "blur(6px)",
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"font-size": "35px",
"font-weight": "600",
"line-height": "1.35",
"letter-spacing": "-0.01em",
"word-spacing": "0",
"font-kerning": "normal",
"text-rendering": "geometricPrecision",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"font-style": "normal",
"backdrop-filter": "blur(6px)"
},
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
"fontSize": 24,
"fontColor": "#cad3f5",
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"backgroundColor": "transparent"
"backgroundColor": "transparent",
"css": {
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
"font-size": "24px",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
}
}
}
}
@@ -352,6 +381,7 @@ See `config.example.jsonc` for detailed configuration options.
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
@@ -360,9 +390,11 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
@@ -370,10 +402,13 @@ See `config.example.jsonc` for detailed configuration options.
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and
`textShadow` remain supported for hand-written or older configs.
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
@@ -963,11 +998,10 @@ This example is intentionally compact. The option table below documents availabl
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
@@ -1009,9 +1043,9 @@ Known-word cache policy:
- Initial sync runs when the integration starts if the cache is missing or stale.
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
@@ -1255,7 +1289,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime and are hidden from the configuration window.
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
+188 -309
View File
@@ -1,34 +1,33 @@
# Installation
## How the Pieces Fit Together
Three steps to get started:
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
1. **Install requirements** — mpv and a few optional extras
2. **Install SubMiner** — from the AUR, or download from GitHub Releases
3. **Launch the app** — first-run setup walks you through dictionaries, the launcher, and everything else
To get a working setup you need:
## 1. Install Requirements
1. **mpv** launched with an IPC socket so SubMiner can read subtitle data
2. **SubMiner** (the Electron overlay app)
3. **Dictionaries** imported into the bundled Yomitan instance (lookups won't work without at least one)
4. **Anki + AnkiConnect** _(optional but recommended)_ for card creation and enrichment
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
The `subminer` launcher script handles step 1 automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or the equivalent named pipe on Windows) — without it the overlay will start but subtitles will never appear.
| Dependency | Status | What it does |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Subtitle sync engine (fallback). Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
## Requirements
### Linux
### System Dependencies
| Dependency | Required | Notes |
| -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| mpv | Yes | Must support IPC sockets (`--input-ipc-server`) |
| Bun | For wrapper | Required for `subminer` CLI wrapper and source builds. Pre-built releases (AppImage, DMG, installer) work without it — only the `subminer` wrapper script needs Bun on `PATH`. |
| ffmpeg | Recommended | Audio extraction and screenshot generation. Without it SubMiner still runs, but audio and image fields on Anki cards will be empty. |
| MeCab + mecab-ipadic | No | Adds part-of-speech data used to filter particles out of N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less precise. |
| fuse2 | Linux only | Required for AppImage |
| yt-dlp | No | Recommended for YouTube playback and subtitle extraction |
### Platform-Specific
**Linux** — one of the following window backends:
**Window backend** — you need one of these depending on your compositor:
- **Hyprland** — native Wayland support (uses `hyprctl`)
- **Sway** — native Wayland support (uses `swaymsg`)
@@ -43,8 +42,10 @@ Wayland has no universal API for window positioning — each compositor exposes
```bash
sudo pacman -S --needed mpv ffmpeg
# Recommended
sudo pacman -S --needed mecab mecab-ipadic
# Optional
sudo pacman -S --needed mecab mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer
sudo pacman -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer
# Optional: subtitle sync (at least one needed for subtitle syncing)
paru -S --needed alass python-ffsubsync
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
@@ -58,8 +59,10 @@ sudo pacman -S --needed xdotool xorg-xwininfo
```bash
sudo apt install mpv ffmpeg
# Recommended
sudo apt install mecab libmecab-dev mecab-ipadic-utf8
# Optional
sudo apt install mecab libmecab-dev mecab-ipadic-utf8 fzf rofi chafa ffmpegthumbnailer yt-dlp
sudo apt install yt-dlp fzf rofi chafa ffmpegthumbnailer
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
sudo apt install xdotool x11-utils
# Optional: subtitle sync
@@ -74,8 +77,10 @@ pip install ffsubsync
```bash
sudo dnf install mpv ffmpeg
# Recommended
sudo dnf install mecab mecab-ipadic
# Optional
sudo dnf install mecab mecab-ipadic fzf rofi chafa ffmpegthumbnailer yt-dlp
sudo dnf install yt-dlp fzf rofi chafa ffmpegthumbnailer
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
sudo dnf install xdotool xorg-x11-utils
# Optional: subtitle sync
@@ -85,38 +90,32 @@ pip install ffsubsync
</details>
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
### macOS
macOS 10.13 or later. Accessibility permission is required for window tracking (see [step 2](#macos-dmg)).
```bash
brew install mpv ffmpeg
# Optional but recommended for annotations
# Recommended
brew install mecab mecab-ipadic
# Optional
brew install yt-dlp fzf rofi chafa ffmpegthumbnailer
brew install yt-dlp fzf chafa ffmpegthumbnailer
# Optional: subtitle sync
brew install alass
pip install ffsubsync
```
**Windows** — Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Keep `mpv.exe` on `PATH` for auto-discovery or set `mpv.executablePath` in config if it lives elsewhere. SubMiner's packaged build handles window tracking directly. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
### Windows
### Optional Tools
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
| Tool | Purpose |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| fzf | Terminal-based video picker (default) |
| rofi | GUI-based video picker |
| chafa | Thumbnail previews in fzf |
| ffmpegthumbnailer | Generate video thumbnails for picker |
| guessit | Better AniSkip title/season/episode parsing for file playback |
| alass | Subtitle sync engine (preferred) — must be on `PATH` or set `subsync.alass_path` in config; subtitle syncing is disabled without it or ffsubsync |
| ffsubsync | Subtitle sync engine (fallback) — must be on `PATH` or set `subsync.ffsubsync_path` in config; subtitle syncing is disabled without it or alass |
No compositor tools or window helpers are needed — native window tracking is built in.
## Linux
## 2. Install SubMiner
### Arch Linux (AUR)
### Arch Linux (AUR) {#arch-aur}
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR if you want the packaged Linux release managed by pacman. The package installs the official SubMiner AppImage plus the `subminer` wrapper.
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR. The package includes the SubMiner AppImage and the `subminer` launcher.
```bash
paru -S subminer-bin
@@ -130,127 +129,73 @@ cd subminer-bin
makepkg -si
```
### AppImage (Recommended)
### Linux (AppImage) {#linux-appimage}
Download the latest AppImage and the `subminer` launcher from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
**Step 1 — Install Bun** (required for the launcher):
```bash
curl -fsSL https://bun.sh/install | bash
```
The `subminer` launcher uses a Bun shebang. The AppImage itself does **not** need Bun — only the launcher does. If you skip the launcher and run the AppImage directly (for example `SubMiner.AppImage --start`), you can skip this step, but you will need to configure `mpv.conf` with `input-ipc-server=/tmp/subminer-socket` manually.
**Step 2 — Download and install:**
Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
```bash
mkdir -p ~/.local/bin
# Download and install AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage
chmod +x ~/.local/bin/SubMiner.AppImage
# Download and install the subminer launcher (recommended)
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download the optional Linux rofi theme
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
::: tip Launcher install is optional
First-run setup can install [Bun](https://bun.sh) and the `subminer` command-line launcher for you automatically. You don't need to download the launcher separately.
The first-run setup window can also install Bun and the packaged `subminer` launcher into an existing writable PATH directory. Both steps are optional.
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-linux).
:::
To check for updates later:
### macOS (DMG) {#macos-dmg}
Download the DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest), open it, and drag `SubMiner.app` into `/Applications`. A ZIP artifact is also available as a fallback.
**Gatekeeper:** If macOS blocks SubMiner on first launch, right-click the app and select **Open** to bypass the warning. Alternatively:
```bash
subminer -u
# or
subminer --update
xattr -d com.apple.quarantine /Applications/SubMiner.app
```
SubMiner verifies AppImage, launcher, and Linux rofi theme downloads against `SHA256SUMS.txt`. If the AppImage or launcher is installed in a protected path, SubMiner does not elevate itself; it shows the exact sudo command to run instead.
**Accessibility permission:** Grant accessibility permission so the overlay can track the mpv window:
On Linux, `subminer -u` performs the AppImage update from the launcher process, so it does not need to start or IPC into the tray app.
1. Open **System Settings****Privacy & Security** → **Accessibility**
2. Enable SubMiner in the list (add it if it does not appear)
::: tip Launcher install is optional
First-run setup can install [Bun](https://bun.sh) and the `subminer` command-line launcher for you automatically. You don't need to download the launcher separately.
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-macos).
:::
### Windows (Installer) {#windows-installer}
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
- `SubMiner-<version>.exe` — installer (recommended)
- `SubMiner-<version>.zip` — portable fallback
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
### From Source
<details>
<summary><b>Linux</b></summary>
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
# if you cloned without --recurse-submodules:
git submodule update --init --recursive
bun install
bun run build
# Optional packaged Linux artifact
# Optional: build AppImage
bun run build:appimage
```
Bundled Yomitan is built during `bun run build`.
If you prefer Make wrappers for local install flows, `make build-launcher` still generates `dist/launcher/subminer` and `make install` still installs the wrapper/theme/AppImage when those artifacts exist.
`make build` also builds the bundled Yomitan Chrome extension from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun.
</details>
## macOS
### DMG (Recommended)
Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Open it and drag `SubMiner.app` into `/Applications`.
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
After the first updater-enabled install, tray update checks can update the macOS app automatically through Electron's standard macOS updater. The updater uses the release ZIP as its payload even when the DMG remains the normal first-install artifact.
Install dependencies using Homebrew:
```bash
brew install mpv ffmpeg
# Optional but recommended if you use N+1, JLPT, or frequency annotations
brew install mecab mecab-ipadic
```
#### Install the `subminer` launcher (recommended)
The `subminer` launcher is the recommended way to use SubMiner on macOS. It launches mpv with the correct IPC socket and SubMiner defaults so you don't need to set up an `mpv.conf` profile manually.
First-run setup can install Bun and the packaged launcher into a writable directory that is already on PATH. It does not edit shell profiles.
Download it from the same [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) page:
```bash
sudo wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O /usr/local/bin/subminer
sudo chmod +x /usr/local/bin/subminer
```
Or with curl:
```bash
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer
sudo chmod +x /usr/local/bin/subminer
```
To check for updates later:
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
::: warning Bun required for the launcher
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
:::
### From Source (macOS)
<details>
<summary><b>macOS</b></summary>
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
@@ -259,122 +204,19 @@ git submodule update --init --recursive
make build-macos
```
The built app will be available in the `release` directory (`.dmg` and `.zip`).
The built app will be in the `release` directory (`.dmg` and `.zip`). For unsigned local builds: `bun run build:mac:unsigned`.
For unsigned local builds:
</details>
```bash
bun run build:mac:unsigned
```
Build and install the launcher alongside the app:
```bash
make install-macos
```
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
```bash
sudo make install-macos PREFIX=/usr/local
```
### Gatekeeper
If macOS blocks SubMiner on first launch, right-click the app and select **Open** to bypass the warning. Alternatively, remove the quarantine attribute:
```bash
xattr -d com.apple.quarantine /Applications/SubMiner.app
```
### Accessibility Permission
After launching SubMiner for the first time, grant accessibility permission:
1. Open **System Settings****Privacy & Security** → **Accessibility**
2. Enable SubMiner in the list (add it if it does not appear)
Without this permission, window tracking will not work and the overlay won't follow the mpv window.
### macOS Usage Notes
**Launching with the `subminer` launcher (recommended):**
```bash
subminer video.mkv
```
The launcher handles the IPC socket and SubMiner defaults automatically. If you prefer to launch mpv manually:
```bash
mpv --input-ipc-server=/tmp/subminer-socket video.mkv
```
**Config location:** `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`).
**MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
Ensure `mecab` is available on your PATH when launching SubMiner.
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
**mpv plugin binary path:**
```ini
binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer
```
## Windows
### Prerequisites
1. Install [`mpv`](https://mpv.io/installation/) and ensure `mpv.exe` is on `PATH`. If mpv is installed elsewhere, you can set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable.
2. Install [`ffmpeg`](https://ffmpeg.org/download.html) and add it to `PATH` — recommended for audio/screenshot extraction (without it, media fields on Anki cards will be empty).
3. _(Optional)_ Install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary for annotation POS filtering.
No compositor tools or window helpers are needed — native window tracking is built in on Windows.
### Installer (Recommended)
Download the latest Windows installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
- `SubMiner-<version>.exe` installs the app, Start menu shortcut, and default files under `Program Files`
- `SubMiner-<version>.zip` is available as a portable fallback
### Getting Started on Windows
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
3. **Optional: install the command-line launcher** — first-run setup can install Bun with winget/Scoop/the official installer and add `%LOCALAPPDATA%\SubMiner\bin\subminer.cmd` to your user PATH. Open a new terminal and type `subminer`.
4. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
```powershell
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
```
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle args, and bundled runtime plugin directly — no `mpv.conf` profile or global mpv plugin install is needed.
### Windows-Specific Notes
- The **SubMiner mpv** shortcut created during first-run setup is the recommended way to launch playback on Windows.
- The optional command-line launcher installs a `subminer.cmd` shim, but users type `subminer`; Windows resolves `.cmd` through `PATHEXT`.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` or the app install directory to PATH.
- First-run plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is in a non-standard location.
- Plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket` — do not keep `/tmp/subminer-socket` on Windows.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
### From Source (Windows)
<details>
<summary><b>Windows</b></summary>
```powershell
git clone https://github.com/ksyasuda/SubMiner.git
cd SubMiner
bun install
# Windows requires building the texthooker-ui submodule manually before
# the main build (Linux/macOS handle this automatically during `bun run build`).
# Windows requires building texthooker-ui manually before the main build
Set-Location vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
@@ -383,86 +225,52 @@ Set-Location ../..
bun run build:win
```
Windows installer builds already get the required NSIS `WinShell` helper through electron-builder's cached `nsis-resources` bundle.
No extra repo-local WinShell plugin install step is required.
</details>
## MPV Plugin
## 3. Launch & First-Run Setup
SubMiner-managed playback loads the bundled mpv plugin at runtime. No separate global mpv plugin install is required when launching from the app, the launcher, or the packaged Windows SubMiner mpv shortcut.
::: warning Important
If first-run setup detects an older global SubMiner mpv plugin under mpv's `scripts` directory, use **Remove legacy mpv plugin** so regular mpv playback stops loading SubMiner.
:::
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
## Anki Setup (Recommended)
If you plan to mine Anki cards (the primary use case for most users):
1. Install [Anki](https://apps.ankiweb.net/).
2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on — open Anki, go to **Tools → Add-ons → Get Add-ons**, enter code `2055492159`.
3. Restart Anki and keep it running while using SubMiner.
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner will connect to it automatically with no extra config needed for basic card creation.
For enrichment configuration (sentence, audio, screenshot fields), see [Anki Integration](/anki-integration).
## First-Run Setup
Run the setup wizard to create a default config and finish initial configuration. You do **not** need to create the config manually — SubMiner handles it.
Launch SubMiner and the setup wizard will open automatically:
```bash
# Linux (AUR install)
subminer app --setup
# Linux (AppImage directly)
~/.local/bin/SubMiner.AppImage --setup
# macOS — launch SubMiner.app from /Applications, or:
subminer app --setup
```
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
On **Windows**, just run `SubMiner.exe` — the setup wizard opens automatically on first launch.
The setup popup walks you through:
The setup wizard walks you through:
- **Config file**: auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
- **mpv plugin**: install the bundled Lua plugin for in-player keybindings
- **Yomitan dictionaries**: import at least one dictionary so lookups work
- **Windows shortcut** _(Windows only)_: optionally create a `SubMiner mpv` Start Menu/Desktop shortcut
- **Command line launcher**: optionally install Bun and the `subminer` launcher to your command-line PATH
- **Config file** auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
- **Yomitan dictionaries** — import at least one dictionary so word lookups work
- **Bun + `subminer` launcher** _(optional)_ — installs the command-line launcher into a writable PATH directory
- **Windows shortcut** _(Windows only)_ create a `SubMiner mpv` Start Menu/Desktop shortcut
The `Finish setup` button follows the normal config/Yomitan readiness checks. Bun and the command-line launcher are optional and never block setup completion.
The `Finish setup` button requires a config file and at least one Yomitan dictionary. Bun and the launcher are optional and never block setup completion.
> [!TIP]
> You can re-open the setup popup at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
> You can re-open the setup wizard at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
Once setup is complete, play a video to verify everything works:
### Play a Video
Once setup is complete:
```bash
subminer video.mkv
```
You should see the overlay appear over mpv. If subtitles are loaded in the video, they will appear as interactive text in the overlay.
You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay.
<details>
<summary><b>More launch examples</b></summary>
On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup — double-click it, or drag a video file onto it.
```bash
# Optional explicit overlay start for setups with plugin auto_start=no
subminer --start video.mkv
### Verify Setup
# Useful launch modes for troubleshooting
subminer --log-level debug video.mkv
SubMiner.AppImage --start --log-level debug
# Or with direct AppImage control
SubMiner.AppImage --background # Background tray service mode
SubMiner.AppImage --start
SubMiner.AppImage --start --dev
SubMiner.AppImage --help # Show all CLI options
```
</details>
## Verify Setup
After completing first-run setup, run the built-in diagnostic to confirm everything is in place:
Run the built-in diagnostic to confirm everything is working:
```bash
subminer doctor
@@ -470,19 +278,90 @@ subminer doctor
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
## Anki Setup (Recommended)
If you plan to mine Anki cards:
1. Install [Anki](https://apps.ankiweb.net/)
2. Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) — open Anki → **Tools → Add-ons → Get Add-ons** → enter code `2055492159`
3. Restart Anki and keep it running while using SubMiner
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner connects automatically with no extra config needed.
For enrichment configuration (sentence, audio, screenshot fields), see [Anki Integration](/anki-integration).
## Updates
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
On Linux, `subminer -u` performs the AppImage update from the launcher process directly.
On macOS, tray update checks can also update the app automatically through Electron's built-in updater.
## How It All Fits Together
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) — without it the overlay starts but subtitles won't appear.
The bundled mpv plugin is injected at runtime automatically — you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
## Platform Notes
### macOS
**MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
Ensure `mecab` is available on your PATH when launching SubMiner.
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
### Windows
- The **SubMiner mpv** shortcut is the recommended way to launch playback. It starts `mpv.exe` with the right IPC socket and subtitle defaults.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` to PATH.
- IPC socket on Windows is `\\.\pipe\subminer-socket` — do not use `/tmp/subminer-socket`.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
## Manual Launcher Install
The `subminer` launcher uses a [Bun](https://bun.sh) shebang, so Bun must be installed. First-run setup can handle this automatically, but if you prefer to do it yourself:
### Linux {#manual-launcher-install-linux}
```bash
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Download the launcher
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
```
### macOS {#manual-launcher-install-macos}
```bash
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Download the launcher
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer
sudo chmod +x /usr/local/bin/subminer
```
## Optional Extras
### Rofi Theme (Linux Only)
SubMiner ships a custom rofi theme bundled in the release assets tarball.
Install path (default auto-detected by `subminer`):
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
SubMiner ships a custom rofi theme in the release assets:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
+2 -2
View File
@@ -6,7 +6,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
- listing libraries and media items
- launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app
- opening an in-app setup window for server selection and authentication
- opening an in-app setup window for server URL and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements
@@ -50,7 +50,7 @@ subminer jellyfin -l \
--password 'your-password'
```
`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
3. List libraries:
+72 -53
View File
@@ -7,10 +7,11 @@
{
// ==========================================
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
// Visible Overlay Auto-Start
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
// SubMiner can still auto-start in the background when this is false.
// ==========================================
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
// ==========================================
// Texthooker Server
@@ -45,6 +46,7 @@
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running.
// ==========================================
"logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
@@ -336,6 +338,7 @@
// ==========================================
// Auto Subtitle Sync
// Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run.
// ==========================================
"subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
@@ -360,29 +363,31 @@
// ==========================================
"subtitleStyle": {
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "35px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"fontWeight": "600", // Font weight setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"fontStyle": "normal", // Font style setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"jlptColors": {
"N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting.
@@ -406,19 +411,21 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
"fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "600", // Font weight setting.
"fontStyle": "normal" // Font style setting.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "24px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
@@ -434,16 +441,18 @@
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
"opacity": 0.95, // Base opacity applied to the sidebar shell.
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
"font-size": "16px", // Font size setting.
"opacity": "0.95", // Opacity setting.
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width 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-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
@@ -463,7 +472,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -512,8 +521,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
"color": "#a6da95" // Color used for known-word highlights.
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -524,15 +532,15 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
"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).
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
@@ -544,6 +552,7 @@
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
// Hot-reload: Jimaku changes apply to the next Jimaku request.
// ==========================================
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
@@ -554,6 +563,7 @@
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
@@ -598,14 +608,23 @@
// ==========================================
// MPV Launcher
// Optional mpv.exe override for Windows playback entry points.
// SubMiner-managed mpv launch and bundled plugin options.
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ==========================================
"mpv": {
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
}, // Optional mpv.exe override for Windows playback entry points.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. 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.
"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.
}, // SubMiner-managed mpv launch and bundled plugin options.
// ==========================================
// Jellyfin
@@ -648,7 +667,7 @@
// ==========================================
"discordPresence": {
"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".
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
+13 -11
View File
@@ -61,6 +61,8 @@ These control playback and subtitle display. They require overlay window focus.
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
## Subtitle & Feature Shortcuts
@@ -68,7 +70,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
@@ -96,17 +98,17 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
| Chord | Action |
| ----- | ------------------------ |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| Chord | Action |
| ----- | -------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
+36 -35
View File
@@ -15,20 +15,21 @@ N+1 highlighting identifies sentences where you know every word except one, maki
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
3. When a subtitle line appears, each token is checked against the cache.
4. If exactly one unknown word remains in the sentence, it is highlighted with `nPlusOneColor` (default: `#c6a0f6`).
5. Already-known tokens can optionally display in `knownWordColor` (default: `#a6da95`).
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
5. Already-known tokens can optionally display in `subtitleStyle.knownWordColor` (default: `#a6da95`).
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
| `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens |
| Option | Default | Description |
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
::: tip
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
@@ -46,10 +47,10 @@ Character-name matches are built from the active merged SubMiner character dicti
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
| Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
@@ -66,15 +67,15 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
| Option | Default | Description |
| ------------------------------------------------ | ------------ | ---------------------------------------- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
@@ -96,22 +97,22 @@ SubMiner loads offline `term_meta_bank_*.json` files from `vendor/yomitan-jlpt-v
**Default colors:**
| Level | Color | Preview |
| --- | --- | --- |
| N1 | `#ed8796` | Red |
| N2 | `#f5a97f` | Peach |
| N3 | `#f9e2af` | Yellow |
| N4 | `#a6e3a1` | Green |
| N5 | `#8aadf4` | Blue |
| Level | Color | Preview |
| ----- | --------- | ------- |
| N1 | `#ed8796` | Red |
| N2 | `#f5a97f` | Peach |
| N3 | `#f9e2af` | Yellow |
| N4 | `#a6e3a1` | Green |
| N5 | `#8aadf4` | Blue |
All colors are customizable via the `subtitleStyle.jlptColors` object.
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
| Option | Default | Description |
| ---------------------------------- | --------- | ----------------------------- |
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
## Runtime Toggles
+28 -20
View File
@@ -1,11 +1,23 @@
# Usage
## Quick Start
Play a video with SubMiner:
```bash
subminer video.mkv
```
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it, or drag a video file onto it.
That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically.
> [!IMPORTANT]
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
> See [Yomitan setup](#yomitan-setup) for details.
::: tip Just finished first-run setup?
If you want Anki card enrichment (sentence, audio, screenshot), the only config you need is `ankiConnect` with your deck name and field names. Here is a minimal working example:
::: tip Anki card enrichment
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
```jsonc
{
@@ -27,25 +39,22 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
## How It Works
1. SubMiner starts the overlay app in the background
2. MPV runs with an IPC socket at `/tmp/subminer-socket`
2. mpv runs with an IPC socket at `/tmp/subminer-socket`
3. The overlay connects and subscribes to subtitle changes
4. Subtitles are tokenized with Yomitan's internal parser
5. Words are displayed as interactive spans in the overlay
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
There are several ways to use SubMiner:
> [!TIP]
> **New users on Linux/macOS: start with the `subminer` wrapper script.** On Windows, use the **SubMiner mpv** shortcut created during first-run setup. Both handle mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
### Ways to Launch
| Approach | Use when | How |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| **`subminer` script** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **The simplest path and recommended starting point.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults directly. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **MPV plugin** (all platforms) | You launch mpv yourself or from another tool (file manager, Jellyfin, etc.). Requires `--input-ipc-server=/tmp/subminer-socket` in your mpv config. | Use `y` chord keybindings inside mpv |
| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
You can use both — the plugin provides in-player controls, while the `subminer` script (Linux/macOS) or the SubMiner mpv shortcut (Windows) is convenient for direct playback.
The mpv plugin is always available — it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload
@@ -72,8 +81,8 @@ subminer # Current directory (uses fzf)
subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
subminer video.mkv # Play specific file (overlay auto-starts)
subminer --start video.mkv # Explicit overlay start (use when auto_start=no in config)
subminer -S video.mkv # Same as above via --start-overlay
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
@@ -128,7 +137,7 @@ SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jel
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
SubMiner.AppImage --jellyfin-libraries
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow)
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
@@ -153,7 +162,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
### Windows mpv Shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish. The global mpv plugin install is optional because SubMiner-managed mpv launches inject the bundled runtime plugin.
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
After setup completes, the shortcut is the normal Windows playback entry point.
@@ -197,13 +206,12 @@ SubMiner.AppImage --setup
Setup flow:
- config file: create the default config directory and prefer `config.jsonc`
- plugin compatibility: optionally install the legacy global mpv plugin; managed launches use the bundled runtime plugin without it
- legacy plugin cleanup: remove detected global SubMiner mpv plugin files from mpv script directories via the OS trash when you do not want regular mpv to load SubMiner
- legacy plugin cleanup: remove detected older global SubMiner mpv plugin files if present (the bundled plugin is injected at runtime automatically)
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
- refresh: re-check plugin + dictionary state without restarting
- refresh: re-check dictionary state without restarting
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
- finish action writes setup completion state and suppresses future auto-open prompts
@@ -336,9 +344,9 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
Press `V` to hide or restore the primary SubMiner subtitle bar. The mpv plugin also binds bare `v` to the same action, overriding mpv's native primary subtitle visibility toggle.
Press `V` to hide or restore the primary SubMiner subtitle bar. The bundled mpv plugin also binds bare `v` to the same action (injected at runtime).
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
+2
View File
@@ -90,6 +90,8 @@ Notes:
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+4 -2
View File
@@ -567,9 +567,11 @@ export function buildSubminerScriptOpts(
logLevel: LogLevel = 'info',
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
...extraParts.map(sanitizeScriptOptValue),
];
if (logLevel !== 'info') {
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
+7 -1
View File
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
appPath: string,
args: LauncherCommandContext['args'],
runtimePluginPath?: string | null,
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
): Promise<void>;
}
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
context: LauncherCommandContext,
deps: MpvCommandDeps = defaultDeps,
): Promise<boolean> {
const { args, appPath, scriptPath, mpvSocketPath } = context;
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
if (!args.mpvIdle) {
return false;
}
@@ -62,6 +63,11 @@ export async function runMpvPostAppCommand(
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
{
...pluginRuntimeConfig,
backend: args.backend,
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
},
);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
if (!ready) {
+13 -3
View File
@@ -72,9 +72,14 @@ function createContext(): LauncherCommandContext {
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
@@ -140,7 +145,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
});
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
const context = createContext();
context.args = {
...context.args,
@@ -149,9 +154,14 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
@@ -164,7 +174,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
mpvProc.exitCode = null;
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = false;
let cleanupSawManagedOverlay = true;
try {
await runPlaybackCommandWithDeps(context, {
@@ -190,7 +200,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
});
assert.equal(cleanupSawManagedOverlay, true);
assert.equal(cleanupSawManagedOverlay, false);
} finally {
state.appPath = '';
state.overlayManagedByLauncher = false;
+5 -2
View File
@@ -7,7 +7,6 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import {
cleanupPlaybackSession,
launchAppCommandDetached,
markOverlayManagedByLauncher,
resolveLauncherRuntimePluginPath,
startMpv,
startOverlay,
@@ -238,6 +237,11 @@ export async function runPlaybackCommandWithDeps(
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
runtimePluginConfig: {
...pluginRuntimeConfig,
backend: args.backend,
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
},
},
);
@@ -263,7 +267,6 @@ export async function runPlaybackCommandWithDeps(
: [],
);
} else if (pluginAutoStartEnabled) {
markOverlayManagedByLauncher(appPath);
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
+117 -30
View File
@@ -5,8 +5,8 @@ import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
import { parseLauncherMpvConfig } from './config/mpv-config.js';
import { readExternalYomitanProfilePath } from './config.js';
import {
getPluginConfigCandidates,
parsePluginRuntimeConfigContent,
buildPluginRuntimeScriptOptParts,
parsePluginRuntimeConfigFromMainConfig,
} from './config/plugin-runtime-config.js';
import { getDefaultSocketPath } from './types.js';
@@ -86,10 +86,34 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
mpv: {
launchMode: ' maximized ',
executablePath: 'ignored-here',
socketPath: '/tmp/custom.sock',
backend: 'x11',
autoStartSubMiner: false,
pauseUntilOverlayReady: false,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
assert.equal(parsed.launchMode, 'maximized');
assert.equal(parsed.socketPath, '/tmp/custom.sock');
assert.equal(parsed.backend, 'x11');
assert.equal(parsed.autoStartSubMiner, false);
assert.equal(parsed.pauseUntilOverlayReady, false);
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
subminerBinaryPath: ' ',
},
});
assert.equal(parsed.subminerBinaryPath, undefined);
});
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
@@ -102,39 +126,102 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
assert.equal(parsed.launchMode, undefined);
});
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
const parsed = parsePluginRuntimeConfigContent(`
# comment
socket_path = /tmp/custom.sock # trailing comment
auto_start = yes
auto_start_visible_overlay = true
auto_start_pause_until_ready = 1
`);
assert.equal(parsed.socketPath, '/tmp/custom.sock');
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig({
auto_start_overlay: false,
texthooker: {
launchAtStartup: false,
},
mpv: {
socketPath: '/tmp/config.sock',
backend: 'sway',
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
assert.equal(parsed.socketPath, '/tmp/config.sock');
assert.equal(parsed.backend, 'sway');
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, true);
assert.equal(parsed.autoStartPauseUntilReady, true);
});
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
const parsed = parsePluginRuntimeConfigContent(`
auto_start = maybe
auto_start_visible_overlay = no
auto_start_pause_until_ready = off
`);
assert.equal(parsed.autoStart, false);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, false);
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig(null);
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, true);
assert.equal(parsed.aniskipButtonKey, 'TAB');
});
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
assert.deepEqual(
getPluginConfigCandidates({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
}),
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
buildPluginRuntimeScriptOptParts(
{
socketPath: '/tmp/config.sock',
binaryPath: '/opt/SubMiner/SubMiner.AppImage',
backend: 'x11',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
'/fallback/SubMiner.AppImage',
),
[
'subminer-binary_path=/opt/SubMiner/SubMiner.AppImage',
'subminer-socket_path=/tmp/config.sock',
'subminer-backend=x11',
'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8',
],
);
});
test('buildPluginRuntimeScriptOptParts strips script-option delimiters from string values', () => {
assert.deepEqual(
buildPluginRuntimeScriptOptParts(
{
socketPath: '/tmp/config.sock,subminer-auto_start=no\nother=yes',
binaryPath: '/opt/SubMiner,\nSubMiner.AppImage',
backend: 'x11',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8,\nF9',
},
'/fallback/SubMiner.AppImage',
),
[
'subminer-binary_path=/opt/SubMiner SubMiner.AppImage',
'subminer-socket_path=/tmp/config.sock subminer-auto_start=no other=yes',
'subminer-backend=x11',
'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8 F9',
],
);
});
+1 -1
View File
@@ -118,7 +118,7 @@ export function createDefaultArgs(
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
const parsed: Args = {
backend: 'auto',
backend: mpvConfig.backend ?? 'auto',
directory: '.',
recursive: false,
profile: '',
+32
View File
@@ -1,6 +1,29 @@
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
import type { Backend } from '../types.js';
import type { LauncherMpvConfig } from '../types.js';
function parseBackend(value: unknown): Backend | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (
normalized === 'auto' ||
normalized === 'hyprland' ||
normalized === 'sway' ||
normalized === 'x11' ||
normalized === 'macos' ||
normalized === 'windows'
) {
return normalized;
}
return undefined;
}
function parseNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
const mpvRaw = root.mpv;
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
@@ -8,5 +31,14 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
return {
launchMode: parseMpvLaunchMode(mpv.launchMode),
socketPath: parseNonEmptyString(mpv.socketPath),
backend: parseBackend(mpv.backend),
autoStartSubMiner:
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
};
}
+55 -105
View File
@@ -1,126 +1,76 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { log } from '../log.js';
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
import type { Backend, LogLevel, PluginRuntimeConfig } from '../types.js';
import { DEFAULT_SOCKET_PATH } from '../types.js';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../src/shared/subminer-plugin-script-opts.js';
import { parseLauncherMpvConfig } from './mpv-config.js';
import { readLauncherMainConfigObject } from './shared-config-reader.js';
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
function rootObject(root: Record<string, unknown> | null, key: string): Record<string, unknown> {
const value = root?.[key];
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function getPluginConfigCandidates(options?: {
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
appDataDir?: string;
}): string[] {
const platform = options?.platform ?? process.platform;
const homeDir = options?.homeDir ?? os.homedir();
const platformPath = getPlatformPath(platform);
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
if (platform === 'win32') {
const appDataDir =
options?.appDataDir?.trim() ||
process.env.APPDATA?.trim() ||
platformPath.join(homeDir, 'AppData', 'Roaming');
return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
if (typeof value !== 'string') return fallback;
const normalized = value.trim().toLowerCase();
if (
normalized === 'auto' ||
normalized === 'hyprland' ||
normalized === 'sway' ||
normalized === 'x11' ||
normalized === 'macos' ||
normalized === 'windows'
) {
return normalized;
}
const xdgConfigHome =
options?.xdgConfigHome?.trim() ||
process.env.XDG_CONFIG_HOME ||
platformPath.join(homeDir, '.config');
return Array.from(
new Set([
platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
]),
);
return fallback;
}
export function parsePluginRuntimeConfigContent(
content: string,
logLevel: LogLevel = 'warn',
export function parsePluginRuntimeConfigFromMainConfig(
root: Record<string, unknown> | null,
): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
const texthooker = rootObject(root, 'texthooker');
return {
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
binaryPath: mpvConfig.subminerBinaryPath ?? '',
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
};
}
const parseBooleanValue = (key: string, value: string): boolean => {
const normalized = value.trim().toLowerCase();
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
return false;
};
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
if (!keyValueMatch) continue;
const key = (keyValueMatch[1] || '').toLowerCase();
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
if (!value) continue;
if (key === 'socket_path') {
runtimeConfig.socketPath = value;
continue;
}
if (key === 'auto_start') {
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
continue;
}
if (key === 'auto_start_visible_overlay') {
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
'auto_start_visible_overlay',
value,
);
continue;
}
if (key === 'auto_start_pause_until_ready') {
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
'auto_start_pause_until_ready',
value,
);
}
}
return runtimeConfig;
export function buildPluginRuntimeScriptOptParts(
runtimeConfig: PluginRuntimeConfig,
fallbackAppPath: string,
): string[] {
return buildSubminerPluginRuntimeScriptOptParts(runtimeConfig, fallbackAppPath);
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const candidates = getPluginConfigCandidates();
const defaults: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
for (const configPath of candidates) {
if (!fs.existsSync(configPath)) continue;
try {
const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8'));
log(
'debug',
logLevel,
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
);
return parsed;
} catch {
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
return defaults;
}
}
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
log(
'debug',
logLevel,
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
);
return defaults;
return parsed;
}
+61 -15
View File
@@ -157,10 +157,10 @@ test('mpv socket command returns socket path from plugin runtime config', () =>
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${expectedSocket}\n`,
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
JSON.stringify({ mpv: { socketPath: expectedSocket } }),
);
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
@@ -175,10 +175,10 @@ test('mpv status exits non-zero when socket is not ready', () => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const socketPath = path.join(root, 'missing.sock');
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\n`,
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
JSON.stringify({ mpv: { socketPath } }),
);
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
@@ -280,6 +280,34 @@ test('launcher config command forwards app configuration window command', () =>
});
});
test('launcher config command suppresses known Electron macOS menu diagnostics', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2',
'printf "%s\\n" "real stderr line" >&2',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
};
const result = runLauncher(['config'], env);
assert.equal(result.status, 0);
assert.equal(result.stderr, 'real stderr line\n');
});
});
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
@@ -293,7 +321,6 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(videoPath, 'fake video content');
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
@@ -308,8 +335,15 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
JSON.stringify({
auto_start_overlay: false,
mpv: {
socketPath,
autoStartSubMiner: false,
pauseUntilOverlayReady: false,
},
}),
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
@@ -373,7 +407,6 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(videoPath, 'fake video content');
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
@@ -388,8 +421,15 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
JSON.stringify({
auto_start_overlay: true,
mpv: {
socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
},
}),
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
@@ -443,7 +483,6 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
JSON.stringify({
@@ -457,8 +496,15 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
JSON.stringify({
auto_start_overlay: true,
mpv: {
socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
},
}),
);
fs.writeFileSync(
appPath,
+122
View File
@@ -114,6 +114,36 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
}
});
test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => {
if (process.platform !== 'linux') return;
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'SubMiner.AppImage');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
'printf "args:%s\\n" "$*"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
try {
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:\n/m);
assert.match(result.stdout, /^argc:2\n/m);
assert.match(result.stdout, /^arg0:--app-ping\n/m);
assert.match(result.stdout, /^arg1:--socket\n/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
@@ -264,6 +294,15 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
});
});
test('buildConfiguredMpvDefaultArgs disables macOS menu shortcuts so SubMiner bindings reach mpv', () => {
withPlatform('darwin', () => {
assert.equal(
buildConfiguredMpvDefaultArgs(makeArgs()).includes('--macos-menu-shortcuts=no'),
true,
);
});
});
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
const pluginDir = '/opt/SubMiner/plugin/subminer';
assert.equal(
@@ -616,6 +655,89 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
}
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
state.appPath = appPath;
state.overlayManagedByLauncher = true;
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
assert.equal(state.overlayManagedByLauncher, true);
assert.equal(state.appPath, appPath);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+115 -23
View File
@@ -8,10 +8,11 @@ import {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
import { nowMs } from './time.js';
import {
commandExists,
@@ -38,6 +39,7 @@ export const state = {
type SpawnTarget = {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
};
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
@@ -45,6 +47,8 @@ type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | '
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
export interface LauncherRuntimePluginPlan {
scriptPath: string | null;
@@ -849,6 +853,7 @@ export async function startMpv(
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
},
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
@@ -916,13 +921,13 @@ export async function startMpv(
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const scriptOpts = buildSubminerScriptOpts(
appPath,
socketPath,
aniSkipMetadata,
args.logLevel,
extraScriptOpts,
);
const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
...runtimeScriptOpts,
...extraScriptOpts,
]);
if (aniSkipMetadata) {
log(
'debug',
@@ -999,6 +1004,7 @@ export async function startOverlay(
): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
@@ -1007,10 +1013,19 @@ export async function startOverlay(
const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
log(
'debug',
args.logLevel,
'SubMiner app is already running; launcher will not stop it after playback',
);
clearOverlayManagedByLauncher();
} else {
markOverlayManagedByLauncher(appPath);
}
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -1037,6 +1052,20 @@ export function markOverlayManagedByLauncher(appPath?: string): void {
state.overlayManagedByLauncher = true;
}
function clearOverlayManagedByLauncher(): void {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
if (result.error) {
log('debug', logLevel, `App ping failed before overlay start: ${result.error.message}`);
return false;
}
return result.status === 0;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
@@ -1144,7 +1173,7 @@ function stopManagedOverlayApp(args: Args): void {
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, {
stdio: 'ignore',
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
if (result.error) {
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
@@ -1161,13 +1190,40 @@ function stopManagedOverlayApp(args: Args): void {
}
}
function buildAppEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
function clearTransportedAppArgs(env: Record<string, string | undefined>): void {
for (const key of Object.keys(env)) {
if (key === TRANSPORTED_APP_ARGC_ENV || /^SUBMINER_APP_ARG_\d+$/.test(key)) {
delete env[key];
}
}
}
function buildTransportedAppArgsEnv(appArgs: string[]): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
[TRANSPORTED_APP_ARGC_ENV]: String(appArgs.length),
};
appArgs.forEach((arg, index) => {
env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`] = arg;
});
return env;
}
function shouldTransportAppArgsForAppImage(appPath: string): boolean {
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
}
function buildAppEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
extraEnv: NodeJS.ProcessEnv = {},
): NodeJS.ProcessEnv {
const env: Record<string, string | undefined> = {
...baseEnv,
SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(),
};
delete env.ELECTRON_RUN_AS_NODE;
clearTransportedAppArgs(env);
Object.assign(env, extraEnv);
const layers = env.VK_INSTANCE_LAYERS;
if (typeof layers === 'string' && layers.trim().length > 0) {
const filtered = layers
@@ -1216,6 +1272,10 @@ export function buildConfiguredMpvDefaultArgs(
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
if (process.platform === 'darwin') {
// macOS menu accelerators do not reach mpv script bindings unless disabled.
mpvArgs.push('--macos-menu-shortcuts=no');
}
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
return mpvArgs;
@@ -1229,6 +1289,14 @@ function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void
}
}
const KNOWN_ELECTRON_MENU_DIAGNOSTIC =
'representedObject is not a WeakPtrToElectronMenuModelAsNSObject';
function filterKnownElectronDiagnostics(chunk: string): string {
const lines = chunk.match(/[^\n]*\n|[^\n]+/g) ?? [];
return lines.filter((line) => !line.includes(KNOWN_ELECTRON_MENU_DIAGNOSTIC)).join('');
}
function attachAppProcessLogging(
proc: ReturnType<typeof spawn>,
options?: {
@@ -1243,8 +1311,12 @@ function attachAppProcessLogging(
if (options?.mirrorStdout) process.stdout.write(chunk);
});
proc.stderr?.on('data', (chunk: string) => {
appendCapturedAppOutput('STDERR', chunk);
if (options?.mirrorStderr) process.stderr.write(chunk);
const filteredChunk = filterKnownElectronDiagnostics(chunk);
if (!filteredChunk) {
return;
}
appendCapturedAppOutput('STDERR', filteredChunk);
if (options?.mirrorStderr) process.stderr.write(filteredChunk);
});
}
@@ -1260,7 +1332,7 @@ function runSyncAppCommand(
} {
const target = resolveAppSpawnTarget(appPath, appArgs);
const result = spawnSync(target.command, target.args, {
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
encoding: 'utf8',
});
if (result.stdout) {
@@ -1268,13 +1340,16 @@ function runSyncAppCommand(
if (mirrorOutput) process.stdout.write(result.stdout);
}
if (result.stderr) {
appendCapturedAppOutput('STDERR', result.stderr);
if (mirrorOutput) process.stderr.write(result.stderr);
const filteredStderr = filterKnownElectronDiagnostics(result.stderr);
if (filteredStderr) {
appendCapturedAppOutput('STDERR', filteredStderr);
if (mirrorOutput) process.stderr.write(filteredStderr);
}
}
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
stderr: result.stderr ? filterKnownElectronDiagnostics(result.stderr) : '',
error: result.error ?? undefined,
};
}
@@ -1290,6 +1365,13 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
}
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
if (shouldTransportAppArgsForAppImage(appPath)) {
return {
command: appPath,
args: [],
env: buildTransportedAppArgsEnv(appArgs),
};
}
if (process.platform !== 'win32') {
return { command: appPath, args: appArgs };
}
@@ -1304,7 +1386,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => {
@@ -1323,7 +1405,7 @@ export function runAppCommandSilently(appPath: string, appArgs: string[]): void
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(proc);
proc.once('error', (error) => {
@@ -1374,7 +1456,7 @@ export function runAppCommandAttached(
return new Promise((resolve, reject) => {
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => {
@@ -1445,7 +1527,7 @@ export function launchAppCommandDetached(
const proc = spawn(target.command, target.args, {
stdio: ['ignore', stdoutFd, stderrFd],
detached: true,
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
@@ -1462,6 +1544,7 @@ export function launchMpvIdleDetached(
appPath: string,
args: Args,
runtimePluginPath?: string | null,
runtimePluginConfig?: PluginRuntimeConfig,
): Promise<void> {
return (async () => {
await terminateTrackedDetachedMpv(args.logLevel);
@@ -1483,8 +1566,17 @@ export function launchMpvIdleDetached(
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
mpvArgs.push('--idle=yes');
const runtimeScriptOpts = runtimePluginConfig
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`,
`--script-opts=${buildSubminerScriptOpts(
appPath,
socketPath,
null,
args.logLevel,
runtimeScriptOpts,
)}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
+57 -13
View File
@@ -58,14 +58,11 @@ function createSmokeCase(name: string): SmokeCase {
fs.mkdirSync(artifactsDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(videoPath, 'fake video fixture');
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\n`,
);
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
const setupState = createDefaultSetupState();
setupState.status = 'completed';
setupState.completedAt = '2026-03-07T00:00:00.000Z';
@@ -136,6 +133,9 @@ if (entry.argv.includes('--start')) {
if (entry.argv.includes('--stop')) {
fs.appendFileSync(stopPath, JSON.stringify(entry) + '\\n');
}
if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
}
process.exit(0);
`,
@@ -350,20 +350,64 @@ test(
},
);
test(
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const env = {
...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1',
};
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-borrow-background',
);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.ok(
appEntries.some(
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
),
);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0);
});
},
);
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
fs.writeFileSync(
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
[
`socket_path=${smokeCase.socketPath}`,
'auto_start=yes',
'auto_start_visible_overlay=yes',
'auto_start_pause_until_ready=yes',
'',
].join('\n'),
path.join(getDefaultConfigDir(smokeCase), 'config.jsonc'),
JSON.stringify({
auto_start_overlay: true,
mpv: {
socketPath: smokeCase.socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
},
}),
);
const env = makeTestEnv(smokeCase);
+14 -5
View File
@@ -1,15 +1,12 @@
import path from 'node:path';
import os from 'node:os';
import type { MpvLaunchMode } from '../src/types/config.js';
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const ROFI_THEME_FILE = 'subminer.rasi';
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
if (platform === 'win32') {
return '\\\\.\\pipe\\subminer-socket';
}
return '/tmp/subminer-socket';
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
}
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
@@ -178,13 +175,25 @@ export interface LauncherJellyfinConfig {
export interface LauncherMpvConfig {
launchMode?: MpvLaunchMode;
socketPath?: string;
backend?: MpvBackend;
autoStartSubMiner?: boolean;
pauseUntilOverlayReady?: boolean;
subminerBinaryPath?: string;
aniskipEnabled?: boolean;
aniskipButtonKey?: string;
}
export interface PluginRuntimeConfig {
socketPath: string;
binaryPath: string;
backend: Backend;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
texthookerEnabled: boolean;
aniskipEnabled: boolean;
aniskipButtonKey: string;
}
export interface CommandExecOptions {
+75 -112
View File
@@ -113,6 +113,7 @@ const windows_helper_1 = require("./window-trackers/windows-helper");
const args_1 = require("./cli/args");
const help_1 = require("./cli/help");
const contracts_1 = require("./shared/ipc/contracts");
const anki_connect_1 = require("./anki-connect");
const startup_mode_flags_1 = require("./main/runtime/startup-mode-flags");
const config_validation_1 = require("./main/config-validation");
const anilist_1 = require("./main/runtime/domains/anilist");
@@ -197,10 +198,13 @@ const character_dictionary_auto_sync_1 = require("./main/runtime/character-dicti
const character_dictionary_auto_sync_completion_1 = require("./main/runtime/character-dictionary-auto-sync-completion");
const character_dictionary_auto_sync_notifications_1 = require("./main/runtime/character-dictionary-auto-sync-notifications");
const current_media_tokenization_gate_1 = require("./main/runtime/current-media-tokenization-gate");
const current_subtitle_snapshot_1 = require("./main/runtime/current-subtitle-snapshot");
const startup_osd_sequencer_1 = require("./main/runtime/startup-osd-sequencer");
const app_updater_1 = require("./main/runtime/update/app-updater");
const fetch_adapter_1 = require("./main/runtime/update/fetch-adapter");
const curl_http_executor_1 = require("./main/runtime/update/curl-http-executor");
const release_assets_1 = require("./main/runtime/update/release-assets");
const release_metadata_policy_1 = require("./main/runtime/update/release-metadata-policy");
const launcher_updater_1 = require("./main/runtime/update/launcher-updater");
const update_notifications_1 = require("./main/runtime/update/update-notifications");
const update_dialogs_1 = require("./main/runtime/update/update-dialogs");
@@ -209,8 +213,7 @@ const update_service_1 = require("./main/runtime/update/update-service");
const support_assets_1 = require("./main/runtime/update/support-assets");
const subtitle_prefetch_runtime_1 = require("./main/runtime/subtitle-prefetch-runtime");
const setup_window_factory_1 = require("./main/runtime/setup-window-factory");
const config_settings_window_1 = require("./main/runtime/config-settings-window");
const config_settings_save_1 = require("./main/runtime/config-settings-save");
const config_settings_runtime_1 = require("./main/runtime/config-settings-runtime");
const youtube_playback_1 = require("./main/runtime/youtube-playback");
const yomitan_profile_policy_1 = require("./main/runtime/yomitan-profile-policy");
const yomitan_read_only_log_1 = require("./main/runtime/yomitan-read-only-log");
@@ -219,7 +222,6 @@ const state_1 = require("./main/state");
const anilist_url_guard_1 = require("./main/anilist-url-guard");
const config_2 = require("./config");
const path_resolution_1 = require("./config/path-resolution");
const jsonc_edit_1 = require("./config/settings/jsonc-edit");
const registry_2 = require("./config/settings/registry");
const subtitle_cue_parser_1 = require("./core/services/subtitle-cue-parser");
const subtitle_prefetch_1 = require("./core/services/subtitle-prefetch");
@@ -293,16 +295,17 @@ const texthookerService = new services_1.Texthooker(() => {
const config = getResolvedConfig();
const characterDictionaryEnabled = config.anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled);
const knownWordColoringEnabled = getRuntimeBooleanOption('subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled);
const nPlusOneColoringEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.nPlusOne.enabled);
return {
enableKnownWordColoring: knownAndNPlusOneEnabled,
enableNPlusOneColoring: knownAndNPlusOneEnabled,
enableKnownWordColoring: knownWordColoringEnabled,
enableNPlusOneColoring: nPlusOneColoringEnabled,
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
enableFrequencyColoring: getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled),
enableJlptColoring: getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt),
characterDictionaryEnabled,
knownWordColor: config.ankiConnect.knownWords.color,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.subtitleStyle.knownWordColor,
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
nameMatchColor: config.subtitleStyle.nameMatchColor,
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
@@ -732,6 +735,16 @@ const youtubePlaybackRuntime = (0, youtube_playback_runtime_1.createYoutubePlayb
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
}, {
socketPath: appState.mpvSocketPath,
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
backend: getResolvedConfig().mpv.backend,
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
}),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -756,12 +769,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
resourcesPath: process.resourcesPath,
appExePath: process.execPath,
});
(0, first_run_setup_plugin_1.syncInstalledFirstRunPluginBinaryPath)({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
binaryPath: process.execPath,
});
const firstRunSetupService = (0, first_run_setup_service_1.createFirstRunSetupService)({
platform: process.platform,
configDir: CONFIG_DIR,
@@ -1233,80 +1240,15 @@ const buildConfigHotReloadRuntimeMainDepsHandler = (0, overlay_1.createBuildConf
},
});
const configHotReloadRuntime = (0, services_1.createConfigHotReloadRuntime)(buildConfigHotReloadRuntimeMainDepsHandler());
function getConfigSettingsSnapshot() {
return (0, jsonc_edit_1.buildConfigSettingsSnapshot)({
configPath: configService.getConfigPath(),
rawConfig: configService.getRawConfig(),
resolvedConfig: configService.getConfig(),
warnings: configService.getWarnings(),
fields: configSettingsFields,
});
}
function isConfigSettingsPatch(value) {
if (!value || typeof value !== 'object') {
return false;
}
const operations = value.operations;
return (Array.isArray(operations) &&
operations.every((operation) => {
if (!operation || typeof operation !== 'object') {
return false;
}
const candidate = operation;
return ((candidate.op === 'set' || candidate.op === 'reset') &&
typeof candidate.path === 'string' &&
configSettingsFields.some((field) => field.configPath === candidate.path));
}));
}
function writeTextFileAtomically(targetPath, content) {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`);
try {
fs.writeFileSync(tempPath, content, 'utf-8');
fs.renameSync(tempPath, targetPath);
}
catch (error) {
try {
fs.rmSync(tempPath, { force: true });
}
catch {
// Best effort cleanup after a failed atomic write.
}
throw error;
}
}
function getRestartRequiredSettingsSections(restartRequiredFields) {
const sections = new Set();
for (const field of configSettingsFields) {
if (restartRequiredFields.some((restartField) => field.configPath === restartField ||
field.configPath.startsWith(`${restartField}.`) ||
restartField.startsWith(`${field.configPath}.`))) {
sections.add(field.section);
}
}
return [...sections].sort();
}
const saveConfigSettingsPatch = (0, config_settings_save_1.createSaveConfigSettingsPatchHandler)({
const configSettingsRuntime = (0, config_settings_runtime_1.createConfigSettingsRuntime)({
fields: configSettingsFields,
getConfigPath: () => configService.getConfigPath(),
getCurrentConfig: () => configService.getConfig(),
getRawConfig: () => configService.getRawConfig(),
getConfig: () => configService.getConfig(),
getWarnings: () => configService.getWarnings(),
getSnapshot: () => getConfigSettingsSnapshot(),
fileExists: (targetPath) => fs.existsSync(targetPath),
readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'),
writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content),
reloadConfigStrict: () => configService.reloadConfigStrict(),
classifyDiff: (previous, next) => (0, services_1.classifyConfigHotReloadDiff)(previous, next),
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(fields),
});
function ensureConfigSettingsFileExists() {
const configPath = configService.getConfigPath();
if (!fs.existsSync(configPath)) {
writeTextFileAtomically(configPath, '{}\n');
}
return configPath;
}
const openConfigSettingsWindow = (0, config_settings_window_1.createOpenConfigSettingsWindowHandler)({
defaultAnkiConnectUrl: config_2.DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new anki_connect_1.AnkiConnectClient(url),
getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => {
appState.configSettingsWindow = window;
@@ -1316,26 +1258,13 @@ const openConfigSettingsWindow = (0, config_settings_window_1.createOpenConfigSe
preloadPath: path.join(__dirname, 'preload-settings.js'),
}),
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
openPath: (targetPath) => electron_1.shell.openPath(targetPath),
ipcMain: electron_1.ipcMain,
ipcChannels: contracts_1.IPC_CHANNELS.request,
log: (message) => logger.error(message),
});
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.getConfigSettingsSnapshot, () => getConfigSettingsSnapshot());
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.saveConfigSettingsPatch, (_event, patch) => {
if (!isConfigSettingsPatch(patch)) {
return {
ok: false,
warnings: [],
error: 'Invalid config settings patch.',
hotReloadFields: [],
restartRequiredFields: [],
restartRequiredSections: [],
};
}
return saveConfigSettingsPatch(patch);
});
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.openConfigSettingsFile, async () => {
const openError = await electron_1.shell.openPath(ensureConfigSettingsFileExists());
return openError.length === 0;
});
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.openConfigSettingsWindow, () => openConfigSettingsWindow());
configSettingsRuntime.registerHandlers();
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
const buildDictionaryRootsHandler = (0, startup_1.createBuildDictionaryRootsMainHandler)({
platform: process.platform,
dirname: __dirname,
@@ -1891,10 +1820,11 @@ function getRuntimeBooleanOption(id, fallback) {
}
function shouldInitializeMecabForAnnotations() {
const config = getResolvedConfig();
const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled);
const knownWordsEnabled = getRuntimeBooleanOption('subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled);
const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.nPlusOne.enabled);
const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt);
const frequencyEnabled = getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled);
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
}
const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinRemoteStopped, startJellyfinRemoteSession, stopJellyfinRemoteSession, runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, } = (0, composers_1.composeJellyfinRuntimeHandlers)({
getResolvedJellyfinConfigMainDeps: {
@@ -1916,6 +1846,17 @@ const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinR
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getPluginRuntimeConfig: () => ({
socketPath: appState.mpvSocketPath,
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
backend: getResolvedConfig().mpv.backend,
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
}),
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
removeSocketPath: (socketPath) => {
@@ -3114,6 +3055,17 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, upd
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
onMpvConnected: () => {
if (appState.sessionBindingsInitialized) {
(0, services_1.sendMpvCommandRuntime)(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
@@ -3272,7 +3224,7 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, upd
},
getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.knownWords.matchMode,
getNPlusOneEnabled: () => getRuntimeBooleanOption('subtitle.annotation.nPlusOne', getResolvedConfig().ankiConnect.knownWords.highlightEnabled),
getNPlusOneEnabled: () => getRuntimeBooleanOption('subtitle.annotation.nPlusOne', getResolvedConfig().ankiConnect.nPlusOne.enabled),
getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => appState.jlptLevelLookup(text),
getJlptEnabled: () => getRuntimeBooleanOption('subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt),
@@ -3698,6 +3650,8 @@ function getUpdateService() {
isPackaged: electron_1.app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor: process.platform === 'darwin' ? () => (0, curl_http_executor_1.createCurlHttpExecutor)() : undefined,
disableDifferentialDownload: process.platform === 'darwin',
isNativeUpdaterSupported: () => (0, app_updater_1.isNativeUpdaterSupported)({
platform: process.platform,
isPackaged: electron_1.app.isPackaged,
@@ -3718,7 +3672,7 @@ function getUpdateService() {
readState: () => updateStateStore.readState(),
writeState: (state) => updateStateStore.writeState(state),
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
shouldFetchReleaseMetadata: () => process.platform !== 'darwin',
shouldFetchReleaseMetadata: ({ appUpdate }) => (0, release_metadata_policy_1.shouldFetchReleaseMetadataForPlatform)(process.platform, appUpdate),
fetchLatestStableRelease: (channel) => (0, release_assets_1.fetchLatestStableRelease)({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel, release) => updateLauncherFromSelectedRelease(launcherPath, channel, release),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
@@ -4083,7 +4037,11 @@ const { registerIpcRuntimeHandlers } = (0, composers_1.composeIpcRuntimeHandlers
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
tokenizeCurrentSubtitle: async () => (0, current_subtitle_snapshot_1.resolveCurrentSubtitleForRenderer)({
currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
}),
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
@@ -4387,7 +4345,7 @@ const { runAndApplyStartupState } = (0, composers_1.composeHeadlessStartupHandle
(0, utils_2.enforceUnsupportedWaylandMode)(args);
},
shouldStartApp: (args) => (0, args_1.shouldStartApp)(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: config_2.DEFAULT_CONFIG,
@@ -4441,7 +4399,12 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['keypress', 'TAB']),
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
onWindowContentReady: () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
@@ -4745,4 +4708,4 @@ function appendClipboardVideoToQueue() {
return appendClipboardVideoToQueueHandler();
}
registerIpcRuntimeHandlers();
//# sourceMappingURL=main.js.map
//# sourceMappingURL=main.js.map
-38
View File
@@ -19,7 +19,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
@@ -744,12 +743,6 @@
"npm": ">=7.0.0"
}
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
"node_modules/@xhayper/discord-rpc": {
"version": "1.3.3",
"license": "ISC",
@@ -3950,37 +3943,6 @@
"node": ">=0.6.0"
}
},
"node_modules/vscode-json-languageservice": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz",
"integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"jsonc-parser": "^3.3.1",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
"dev": true,
+2 -3
View File
@@ -50,8 +50,8 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun 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/anki-jimaku-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/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.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/jimaku-download-path.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/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js 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/anki-jimaku-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/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.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/jimaku-download-path.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/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
@@ -118,7 +118,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
+3 -75
View File
@@ -1,75 +1,3 @@
# SubMiner configuration
# Place this file in ~/.config/mpv/script-opts/
# Path to SubMiner binary (leave empty for auto-detection)
# Auto-detection searches common locations, including:
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
# - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/local/bin/subminer, /usr/bin/SubMiner, /usr/bin/subminer
binary_path=
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
# Windows installs rewrite this to \\.\pipe\subminer-socket during installation.
socket_path=/tmp/subminer-socket
# Enable texthooker WebSocket server
texthooker_enabled=yes
# Texthooker WebSocket port
texthooker_port=5174
# Window manager backend: auto, hyprland, sway, x11, macos, windows
# "auto" detects based on environment variables
backend=auto
# Automatically start overlay when a file is loaded
# Runs only when mpv input-ipc-server matches socket_path.
auto_start=yes
# Automatically show visible overlay when overlay starts
# Runs only when mpv input-ipc-server matches socket_path.
auto_start_visible_overlay=yes
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status
osd_messages=yes
# Log level for plugin and SubMiner binary: debug, info, warn, error
log_level=info
# Enable AniSkip intro detection + markers.
aniskip_enabled=yes
# Force title (optional). Launcher fills this from guessit when available.
aniskip_title=
# Force season (optional). Launcher fills this from guessit when available.
aniskip_season=
# Force MAL id (optional). Leave blank for title lookup.
aniskip_mal_id=
# Force episode number (optional). Leave blank for filename/title detection.
aniskip_episode=
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
aniskip_payload=
# Show intro skip OSD button while inside OP range.
aniskip_show_button=yes
# OSD text shown for intro skip action.
# `%s` is replaced by keybinding.
aniskip_button_text=You can skip by pressing %s
# Keybinding to execute intro skip when button is visible.
aniskip_button_key=TAB
# OSD hint duration in seconds (shown during first 3s of intro).
aniskip_button_duration=3
# MPV keybindings provided by plugin/subminer/main.lua:
# y-s start, y-S stop, y-t toggle visible overlay
# SubMiner managed playback config lives in SubMiner config.jsonc.
# This file is intentionally empty so installed/default mpv script-opts do not
# override the app config modal or generated config file.
+8 -2
View File
@@ -18,8 +18,14 @@ function M.create(ctx)
local function is_macos()
local platform = mp.get_property("platform") or ""
if platform == "macos" or platform == "darwin" then
return true
if platform ~= "" then
local normalized = platform:lower()
if normalized == "macos" or normalized == "darwin" or normalized == "osx" then
return true
end
if normalized == "windows" or normalized == "win32" or normalized == "linux" then
return false
end
end
local ostype = os.getenv("OSTYPE") or ""
return ostype:find("darwin") ~= nil
+58 -17
View File
@@ -1,5 +1,8 @@
local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
function M.create(ctx)
local mp = ctx.mp
local opts = ctx.opts
@@ -52,6 +55,11 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false)
end
local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation
end
local function rearm_managed_subtitle_defaults()
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
return false
@@ -63,13 +71,58 @@ function M.create(ctx)
return true
end
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then
return
end
if media_identity ~= nil and state.current_media_identity ~= media_identity then
return
end
if not resolve_auto_start_enabled() then
schedule_aniskip_fetch("file-loaded", 0)
return
end
local has_matching_socket = rearm_managed_subtitle_defaults()
if not has_matching_socket then
if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then
mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function()
start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt + 1)
end)
return
end
subminer_log(
"info",
"lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
rearm_pause_until_ready = not same_media_loaded,
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
end
local function on_file_loaded()
local media_identity = resolve_media_identity()
local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity
local same_media_reload = (
media_identity ~= nil
and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity
)
local same_media_loaded = (
media_identity ~= nil
and previous_media_identity ~= nil
and media_identity == previous_media_identity
)
state.pending_reload_media_identity = nil
state.current_media_identity = media_identity
@@ -92,32 +145,18 @@ function M.create(ctx)
if not preserve_active_auto_start_gate then
process.disarm_auto_play_ready_gate()
end
has_matching_socket = rearm_managed_subtitle_defaults()
if should_auto_start then
if not has_matching_socket then
subminer_log(
"info",
"lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
return
end
rearm_managed_subtitle_defaults()
schedule_aniskip_fetch("file-loaded", 0)
end
local function on_shutdown()
next_auto_start_retry_generation()
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
@@ -139,6 +178,8 @@ function M.create(ctx)
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
return
end
next_auto_start_retry_generation()
state.current_media_identity = nil
state.pending_reload_media_identity = nil
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay()
+4 -4
View File
@@ -27,16 +27,16 @@ function M.load(options_lib, default_socket_path)
local opts = {
binary_path = "",
socket_path = default_socket_path,
texthooker_enabled = true,
texthooker_enabled = false,
texthooker_port = 5174,
backend = "auto",
auto_start = true,
auto_start_visible_overlay = true,
auto_start = false,
auto_start_visible_overlay = false,
auto_start_pause_until_ready = true,
auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true,
log_level = "info",
aniskip_enabled = true,
aniskip_enabled = false,
aniskip_title = "",
aniskip_season = "",
aniskip_mal_id = "",
+182 -38
View File
@@ -2,12 +2,15 @@ local M = {}
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
function M.create(ctx)
local mp = ctx.mp
local utils = ctx.utils
local opts = ctx.opts
local state = ctx.state
local binary = ctx.binary
@@ -17,6 +20,8 @@ function M.create(ctx)
local show_osd = ctx.log.show_osd
local normalize_log_level = ctx.log.normalize_log_level
local run_control_command_async
local APP_ARGC_ENV = "SUBMINER_APP_ARGC"
local APP_ARG_PREFIX = "SUBMINER_APP_ARG_"
local function resolve_visible_overlay_startup()
local raw_visible_overlay = opts.auto_start_visible_overlay
@@ -112,10 +117,12 @@ function M.create(ctx)
local function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
state.auto_play_ready_gate_armed = false
if was_armed and should_resume then
state.auto_play_ready_should_resume_playback = false
if was_armed and should_resume and should_resume_playback then
mp.set_property_native("pause", false)
end
end
@@ -124,17 +131,26 @@ function M.create(ctx)
if not state.auto_play_ready_gate_armed then
return
end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
if should_resume_playback then
mp.set_property_native("pause", false)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
else
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
end
end
local function arm_auto_play_ready_gate()
if state.auto_play_ready_gate_armed then
local was_armed = state.auto_play_ready_gate_armed
if was_armed then
clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
end
if not was_armed then
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD)
@@ -164,10 +180,15 @@ function M.create(ctx)
local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready")
if state.suppress_ready_overlay_restore then
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
return
end
if state.overlay_running and resolve_visible_overlay_startup() then
if force_ready_overlay_restore then
state.suppress_ready_overlay_restore = false
end
if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then
run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
})
@@ -186,7 +207,6 @@ function M.create(ctx)
end
if action == "start" then
table.insert(args, "--background")
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend)
@@ -199,7 +219,10 @@ function M.create(ctx)
table.insert(args, "--socket")
table.insert(args, socket_path)
local should_show_visible = resolve_visible_overlay_startup()
local should_show_visible = overrides.show_visible_overlay
if should_show_visible == nil then
should_show_visible = resolve_visible_overlay_startup()
end
if should_show_visible then
table.insert(args, "--show-visible-overlay")
else
@@ -215,12 +238,75 @@ function M.create(ctx)
return args
end
local function is_appimage_binary(path)
return environment.is_linux() and type(path) == "string" and path:lower():match("%.appimage$") ~= nil
end
local function append_transport_env(env, args)
local count = math.max(#args - 1, 0)
env[#env + 1] = APP_ARGC_ENV .. "=" .. tostring(count)
for index = 2, #args do
env[#env + 1] = APP_ARG_PREFIX .. tostring(index - 2) .. "=" .. tostring(args[index])
end
end
local function env_has_name(env, name)
local prefix = name .. "="
for _, value in ipairs(env) do
if type(value) == "string" and value:sub(1, #prefix) == prefix then
return true
end
end
return false
end
local function append_default_app_log_env(env)
local log_dir = environment.join_path(environment.resolve_subminer_config_dir(), "logs")
local date = os.date("%Y-%m-%d")
if not env_has_name(env, "SUBMINER_APP_LOG") then
env[#env + 1] = "SUBMINER_APP_LOG=" .. environment.join_path(log_dir, "app-" .. date .. ".log")
end
if not env_has_name(env, "SUBMINER_MPV_LOG") then
env[#env + 1] = "SUBMINER_MPV_LOG=" .. environment.join_path(log_dir, "mpv-" .. date .. ".log")
end
end
local function build_appimage_subprocess_env(args)
local env = {}
if utils and type(utils.get_env_list) == "function" then
for _, value in ipairs(utils.get_env_list()) do
if
type(value) == "string"
and not value:match("^" .. APP_ARGC_ENV .. "=")
and not value:match("^" .. APP_ARG_PREFIX .. "%d+=")
then
env[#env + 1] = value
end
end
end
append_default_app_log_env(env)
append_transport_env(env, args)
return env
end
local function build_subprocess_command(args)
if is_appimage_binary(args[1]) then
return {
args = { args[1] },
env = build_appimage_subprocess_env(args),
}
end
return { args = args }
end
run_control_command_async = function(action, overrides, callback)
local args = build_command_args(action, overrides)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
@@ -232,11 +318,36 @@ function M.create(ctx)
end)
end
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
attempt = attempt or 1
run_control_command_async("app-ping", nil, function(_ok, result)
local status = result and result.status
local is_running = status == 0
local is_not_running = status == 1
if (expected_running and is_running) or ((not expected_running) and is_not_running) then
on_ready()
return
end
if attempt >= OVERLAY_RESTART_PING_MAX_ATTEMPTS then
subminer_log("warn", "process", "Timed out waiting for SubMiner app to " .. label)
if on_timeout then
on_timeout()
end
return
end
mp.add_timeout(OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS, function()
wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt + 1)
end)
end)
end
local function run_binary_command_async(args, callback)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
@@ -299,7 +410,15 @@ function M.create(ctx)
if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
local socket_path = overrides.socket_path or opts.socket_path
if not state.auto_play_ready_gate_armed then
local should_pause_until_ready = (
overrides.rearm_pause_until_ready == true
and resolve_visible_overlay_startup()
and resolve_pause_until_ready()
and has_matching_mpv_ipc_socket(socket_path)
)
if should_pause_until_ready then
arm_auto_play_ready_gate()
elseif not state.auto_play_ready_gate_armed then
disarm_auto_play_ready_gate()
end
local visibility_action = resolve_visible_overlay_startup()
@@ -347,9 +466,11 @@ function M.create(ctx)
end
state.overlay_running = true
local command = build_subprocess_command(args)
mp.command_native_async({
name = "subprocess",
args = args,
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
@@ -501,38 +622,61 @@ function M.create(ctx)
subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...")
run_control_command_async("stop", nil, function()
run_control_command_async("stop", nil, function(ok, result)
if not ok then
local reason = result and result.stderr or "unknown error"
subminer_log("warn", "process", "Restart stop command failed: " .. reason)
show_osd("Restart failed")
return
end
state.overlay_running = false
state.texthooker_running = false
disarm_auto_play_ready_gate()
state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
local start_args = build_command_args("start")
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
wait_for_app_ping_state(false, "release the single-instance lock", function()
local start_args = build_command_args("start", {
show_visible_overlay = true,
})
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
state.overlay_running = true
mp.command_native_async({
name = "subprocess",
args = start_args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
subminer_log(
"error",
"process",
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
)
show_osd("Restart failed")
else
show_osd("Restarted successfully")
state.overlay_running = true
local command = build_subprocess_command(start_args)
mp.command_native_async({
name = "subprocess",
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
subminer_log(
"error",
"process",
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
)
show_osd("Restart failed")
else
wait_for_app_ping_state(true, "own the single-instance lock", function()
run_control_command_async("show-visible-overlay")
show_osd("Restarted successfully")
end, function()
run_control_command_async("show-visible-overlay")
show_osd("Restarted successfully")
end)
end
end)
if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end)
end
end, function()
show_osd("Restart failed")
end)
if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end)
end
end)
end
+2
View File
@@ -151,6 +151,8 @@ function M.create(ctx)
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "markWatched" then
return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
+3
View File
@@ -30,11 +30,14 @@ function M.new()
prompt_shown = false,
},
auto_play_ready_gate_armed = false,
auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false,
current_media_identity = nil,
pending_reload_media_identity = nil,
auto_start_retry_generation = 0,
session_binding_generation = 0,
session_binding_names = {},
session_numeric_binding_names = {},
+9
View File
@@ -220,6 +220,14 @@ local ctx = {
actionType = "mpv-command",
command = { "quit" },
},
{
key = {
code = "KeyW",
modifiers = {},
},
actionType = "session-action",
actionId = "markWatched",
},
{
key = {
code = "KeyA",
@@ -307,6 +315,7 @@ local expected_cli_bindings = {
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
{ keys = "w", flag = "--mark-watched" },
}
for _, expected in ipairs(expected_cli_bindings) do
+465 -1
View File
@@ -12,6 +12,7 @@ local function run_plugin_scenario(config)
logs = {},
property_sets = {},
periodic_timers = {},
timeouts = {},
}
local function make_mp_stub()
@@ -22,6 +23,11 @@ local function run_plugin_scenario(config)
return config.platform or "linux"
end
if name == "input-ipc-server" then
if config.input_ipc_server_sequence then
config.input_ipc_server_sequence_index = (config.input_ipc_server_sequence_index or 0) + 1
local index = config.input_ipc_server_sequence_index
return config.input_ipc_server_sequence[index] or config.input_ipc_server_sequence[#config.input_ipc_server_sequence] or ""
end
return config.input_ipc_server or ""
end
if name == "filename/no-ext" then
@@ -40,6 +46,9 @@ local function run_plugin_scenario(config)
end
function mp.get_property_native(name)
if name == "pause" then
return config.paused == true
end
if name == "osd-dimensions" then
return config.osd_dimensions or {
w = 1280,
@@ -108,11 +117,26 @@ local function run_plugin_scenario(config)
return
end
end
for _, value in ipairs(args) do
if value == "--app-ping" then
config.app_ping_index = (config.app_ping_index or 0) + 1
local statuses = config.app_ping_statuses or { 1 }
local status = statuses[config.app_ping_index] or statuses[#statuses] or 1
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
return
end
if value == "--stop" and config.stop_command_fails then
local stderr = config.stop_command_stderr or "stop failed"
callback(false, { status = 1, stdout = "", stderr = stderr }, stderr)
return
end
end
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
end
end
function mp.add_timeout(seconds, callback)
recorded.timeouts[#recorded.timeouts + 1] = seconds
local timeout = {
killed = false,
}
@@ -185,6 +209,9 @@ local function run_plugin_scenario(config)
name = name,
value = value,
}
if name == "pause" then
config.paused = value == true
end
end
function mp.set_property(name, value)
recorded.property_sets[#recorded.property_sets + 1] = {
@@ -222,6 +249,10 @@ local function run_plugin_scenario(config)
return table.concat(parts, "/")
end
function utils.get_env_list()
return config.env_list or {}
end
function utils.parse_json(json)
if json == '{"enabled":true,"amount":125}' then
return {
@@ -398,6 +429,29 @@ local function find_control_call(async_calls, flag)
return nil
end
local function find_nth_control_call(async_calls, flag, target_count)
local count = 0
for _, call in ipairs(async_calls) do
local args = call.args or {}
local has_flag = false
local has_start = false
for _, value in ipairs(args) do
if value == flag then
has_flag = true
elseif value == "--start" then
has_start = true
end
end
if has_flag and not has_start then
count = count + 1
if count == target_count then
return call
end
end
end
return nil
end
local function count_control_calls(async_calls, flag)
local count = 0
for _, call in ipairs(async_calls) do
@@ -503,6 +557,35 @@ local function count_osd_message(messages, target)
return count
end
local function has_timeout(timeouts, target)
for _, seconds in ipairs(timeouts) do
if math.abs(seconds - target) < 0.0001 then
return true
end
end
return false
end
local function env_has(call, target)
local env = (call and call.env) or {}
for _, value in ipairs(env) do
if value == target then
return true
end
end
return false
end
local function env_has_prefix(call, target)
local env = (call and call.env) or {}
for _, value in ipairs(env) do
if type(value) == "string" and value:sub(1, #target) == target then
return true
end
end
return false
end
local function count_property_set(property_sets, name, value)
local count = 0
for _, call in ipairs(property_sets) do
@@ -537,6 +620,7 @@ local function has_key_binding(recorded, keys, name)
end
local binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage"
do
local recorded, err = run_plugin_scenario({
@@ -559,6 +643,46 @@ do
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/episode-01.mkv",
media_title = "Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for new-media rearm scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/episode-02.mkv"
scenario.media_title = "Episode 2"
fire_event(recorded, "file-loaded")
assert_true(
count_start_calls(recorded.async_calls) == 1,
"new media after prior playback should reuse the running overlay"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
"new media after prior playback should re-arm pause-until-ready"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2,
"new media after prior playback should resume only after readiness"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -569,6 +693,283 @@ do
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server_sequence = { "", "", "/tmp/subminer-socket" },
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for delayed socket auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(find_start_call(recorded.async_calls) ~= nil, "delayed socket auto-start should eventually issue --start")
assert_true(
has_property_set(recorded.property_sets, "pause", true),
"delayed socket auto-start should arm pause-until-ready once the socket is available"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
platform = "osx",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for macOS platform alias scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "macOS platform alias auto-start should issue --start")
assert_true(
call_has_arg(start_call, "macos"),
"macOS platform alias auto-start should pass macos backend instead of falling back to x11"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = appimage_path,
auto_start = "no",
socket_path = "/tmp/subminer-socket",
},
files = {
[appimage_path] = true,
},
env_list = {
"PATH=/usr/bin",
"SUBMINER_APP_ARGC=stale",
"SUBMINER_APP_ARG_0=--stale",
},
})
assert_true(recorded ~= nil, "plugin failed to load for AppImage env transport scenario: " .. tostring(err))
recorded.script_messages["subminer-start"]("texthooker=no")
local call = recorded.async_calls[#recorded.async_calls]
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
assert_true(env_has(call, "SUBMINER_APP_ARGC=7"), "AppImage subprocess should transport app arg count")
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
assert_true(
env_has(call, "SUBMINER_APP_ARG_1=--managed-playback"),
"AppImage subprocess should transport --managed-playback"
)
assert_true(
not env_has(call, "SUBMINER_APP_ARG_1=--background"),
"AppImage subprocess should not transport --background for video-owned playback"
)
assert_true(env_has(call, "SUBMINER_APP_ARG_6=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
assert_true(
not env_has(call, "SUBMINER_APP_ARG_0=--stale"),
"AppImage subprocess should remove stale transported args"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
app_ping_statuses = { 0, 1, 0 },
option_overrides = {
binary_path = binary_path,
auto_start = "no",
auto_start_visible_overlay = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual visible restart scenario: " .. tostring(err))
local restart_binding = nil
for _, candidate in ipairs(recorded.key_bindings) do
if candidate.name == "subminer-restart" then
restart_binding = candidate
break
end
end
assert_true(restart_binding ~= nil, "restart binding should be registered")
restart_binding.fn()
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "manual restart should issue --start command")
local start_index = find_call_index(recorded.async_calls, start_call) or 0
local old_app_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 1)
local old_app_stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
local new_app_started_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
assert_true(old_app_ping ~= nil, "manual restart should ping before waiting for old app shutdown")
assert_true(old_app_stopped_ping ~= nil, "manual restart should keep pinging until old app shutdown")
assert_true(new_app_started_ping ~= nil, "manual restart should ping after start until the new app is running")
assert_true(
(find_call_index(recorded.async_calls, old_app_ping) or 0) < start_index,
"manual restart should wait for old app ping before starting"
)
assert_true(
(find_call_index(recorded.async_calls, old_app_stopped_ping) or 0) < start_index,
"manual restart should wait for old app stopped ping before starting"
)
assert_true(
start_index < (find_call_index(recorded.async_calls, new_app_started_ping) or 0),
"manual restart should wait for new app running ping after starting"
)
assert_true(
call_has_arg(start_call, "--show-visible-overlay"),
"manual restart should bring the visible overlay back after process reload"
)
assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"),
"manual restart should not restart into hidden visible-overlay state"
)
assert_true(
not has_timeout(recorded.timeouts, 0.5),
"manual restart should use app-ping readiness instead of a fixed 0.5s start delay"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual restart should re-assert visible overlay after the restarted app is launched"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
app_ping_statuses = { 0, 2, 1, 0 },
option_overrides = {
binary_path = binary_path,
auto_start = "no",
auto_start_visible_overlay = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(
recorded ~= nil,
"plugin failed to load for transient app-ping failure restart scenario: " .. tostring(err)
)
recorded.script_messages["subminer-restart"]()
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "manual restart should start after app-ping reports stopped")
local start_index = find_call_index(recorded.async_calls, start_call) or 0
local failed_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
local stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
assert_true(failed_ping ~= nil, "manual restart should retry after transient app-ping failure")
assert_true(stopped_ping ~= nil, "manual restart should observe stopped app-ping status")
assert_true(
(find_call_index(recorded.async_calls, failed_ping) or 0) < start_index,
"manual restart should not treat app-ping status 2 as stopped"
)
assert_true(
(find_call_index(recorded.async_calls, stopped_ping) or 0) < start_index,
"manual restart should wait for explicit stopped app-ping status"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
app_ping_statuses = { 0, 1, 0 },
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for gated restart pause scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 1,
"gated restart should start from an armed pause gate"
)
recorded.script_messages["subminer-restart"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual restart should clear a startup gate without resuming playback"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
app_ping_statuses = { 1, 0 },
option_overrides = {
binary_path = binary_path,
auto_start = "no",
auto_start_visible_overlay = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for restart ready restore scenario: " .. tostring(err))
assert_true(
recorded.script_messages["subminer-toggle"] ~= nil,
"subminer-toggle script message not registered"
)
assert_true(
recorded.script_messages["subminer-restart"] ~= nil,
"subminer-restart script message not registered"
)
assert_true(
recorded.script_messages["subminer-autoplay-ready"] ~= nil,
"subminer-autoplay-ready script message not registered"
)
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-restart"]()
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
stop_command_fails = true,
stop_command_stderr = "stop refused",
option_overrides = {
binary_path = binary_path,
},
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for failed restart-stop scenario: " .. tostring(err))
recorded.script_messages["subminer-restart"]()
assert_true(find_control_call(recorded.async_calls, "--stop") ~= nil, "restart should attempt stop")
assert_true(count_start_calls(recorded.async_calls) == 0, "restart should not start overlay when stop fails")
assert_true(
has_osd_message(recorded.osd, "SubMiner: Restart failed"),
"restart stop failure should show failure OSD"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
aniskip_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
@@ -608,6 +1009,7 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
files = {
[binary_path] = true,
@@ -644,6 +1046,7 @@ do
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
aniskip_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
@@ -682,6 +1085,7 @@ do
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "no",
texthooker_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
@@ -737,6 +1141,7 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
media_title = "Random Movie",
files = {
@@ -765,6 +1170,7 @@ do
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "no",
texthooker_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
@@ -793,6 +1199,7 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
media_title = "Sample Show S01E01",
mal_lookup_stdout = "__MAL_FOUND__",
@@ -818,6 +1225,7 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
media_title = "Sample Show S01E01",
time_pos = 13,
@@ -852,6 +1260,7 @@ do
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "no",
texthooker_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
@@ -864,7 +1273,10 @@ do
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true(call_has_arg(start_call, "--background"), "auto-start should launch SubMiner in background mode")
assert_true(
not call_has_arg(start_call, "--background"),
"auto-start should not mark video-owned playback as background/tray mode"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as launcher-managed playback"
@@ -1023,6 +1435,37 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for pre-paused pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 1,
"pre-paused pause-until-ready should still arm the gate"
)
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"pre-paused pause-until-ready should leave playback paused when ready"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -1236,6 +1679,27 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for default config scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(
start_call == nil,
"plugin should not auto-start from built-in defaults without managed config script opts"
)
end
do
local recorded, err = run_plugin_scenario({
platform = "windows",
+141
View File
@@ -48,3 +48,144 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
console.info = originalInfo;
}
});
test('AnkiConnectClient lists decks and note type fields', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'deckNames') {
return { data: { result: ['Core', 'Mining'], error: null } };
}
if (body.action === 'modelNames') {
return { data: { result: ['Japanese sentences'], error: null } };
}
if (body.action === 'modelFieldNames') {
return { data: { result: ['Expression', 'Sentence'], error: null } };
}
return { data: { result: [], error: null } };
},
};
const typedClient = client as unknown as AnkiConnectClient;
assert.deepEqual(await typedClient.deckNames(), ['Core', 'Mining']);
assert.deepEqual(await typedClient.modelNames(), ['Japanese sentences']);
assert.deepEqual(await typedClient.modelFieldNames('Japanese sentences'), [
'Expression',
'Sentence',
]);
assert.deepEqual(
calls.map((call) => call.action),
['deckNames', 'modelNames', 'modelFieldNames'],
);
});
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'findNotes') {
return { data: { result: [3, 1, 2], error: null } };
}
if (body.action === 'notesInfo') {
return {
data: {
result: [
{ fields: { Sentence: { value: 'x' }, Expression: { value: 'y' } } },
{ fields: { Reading: { value: 'z' } } },
],
error: null,
},
};
}
return { data: { result: [], error: null } };
},
};
assert.deepEqual(
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining "Current"'),
['Expression', 'Reading', 'Sentence'],
);
assert.deepEqual(calls[0], {
action: 'findNotes',
params: { query: 'deck:"Mining \\"Current\\""' },
});
assert.deepEqual(calls[1], {
action: 'notesInfo',
params: { notes: [3, 1, 2] },
});
});
test('AnkiConnectClient treats negative deck note sample sizes as empty samples', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'findNotes') {
return { data: { result: [3, 1, 2], error: null } };
}
if (body.action === 'notesInfo') {
return {
data: {
result: [{ fields: { Sentence: { value: 'x' } } }],
error: null,
},
};
}
return { data: { result: [], error: null } };
},
};
assert.deepEqual(
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1),
[],
);
assert.deepEqual(
calls.map((call) => call.action),
['findNotes'],
);
});
test('AnkiConnectClient derives model names from sampled notes in a deck', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'findNotes') {
return { data: { result: [5, 4], error: null } };
}
if (body.action === 'notesInfo') {
return {
data: {
result: [{ modelName: 'Lapis Morph' }, { modelName: 'Kiku' }],
error: null,
},
};
}
return { data: { result: [], error: null } };
},
};
assert.deepEqual(await (client as unknown as AnkiConnectClient).modelNamesForDeck('Mining'), [
'Kiku',
'Lapis Morph',
]);
assert.deepEqual(
calls.map((call) => call.action),
['findNotes', 'notesInfo'],
);
});
+70
View File
@@ -156,6 +156,76 @@ export class AnkiConnectClient {
return (result as number[]) || [];
}
async deckNames(): Promise<string[]> {
const result = await this.invoke('deckNames');
return Array.isArray(result)
? result.filter((value): value is string => typeof value === 'string').sort()
: [];
}
async modelNames(): Promise<string[]> {
const result = await this.invoke('modelNames');
return Array.isArray(result)
? result.filter((value): value is string => typeof value === 'string').sort()
: [];
}
async modelFieldNames(modelName: string): Promise<string[]> {
const result = await this.invoke('modelFieldNames', { modelName });
return Array.isArray(result)
? result.filter((value): value is string => typeof value === 'string').sort()
: [];
}
private async noteInfosForDeck(
deckName: string,
sampleSize = 100,
): Promise<Record<string, unknown>[]> {
const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 });
if (noteIds.length === 0) {
return [];
}
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
const normalizedSampleSize = Math.min(
noteIds.length,
Math.max(0, Math.floor(finiteSampleSize)),
);
if (normalizedSampleSize === 0) {
return [];
}
return this.notesInfo(noteIds.slice(0, normalizedSampleSize));
}
async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
const fields = new Set<string>();
for (const noteInfo of noteInfos) {
const noteFields = noteInfo.fields;
if (!noteFields || typeof noteFields !== 'object' || Array.isArray(noteFields)) {
continue;
}
for (const fieldName of Object.keys(noteFields)) {
fields.add(fieldName);
}
}
return [...fields].sort();
}
async modelNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
const modelNames = new Set<string>();
for (const noteInfo of noteInfos) {
const modelName = noteInfo.modelName;
if (typeof modelName === 'string' && modelName.length > 0) {
modelNames.add(modelName);
}
}
return [...modelNames].sort();
}
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
const result = await this.invoke('notesInfo', { notes: noteIds });
return (result as Record<string, unknown>[]) || [];
+2 -1
View File
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
}
private isKnownWordCacheEnabled(): boolean {
return this.deps.getConfig().knownWords?.highlightEnabled === true;
const config = this.deps.getConfig();
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
}
private shouldAddMinedWordsImmediately(): boolean {
+4 -2
View File
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
}
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const wasKnownWordCacheEnabled =
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
? this.getKnownWordCacheLifecycleConfig(this.config)
: null;
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
};
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config);
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const nextKnownWordCacheEnabled =
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
if (this.started) {
+13
View File
@@ -94,6 +94,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--toggle-stats-overlay',
'--mark-watched',
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
@@ -110,6 +111,7 @@ test('parseArgs captures session action forwarding flags', () => {
]);
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
@@ -236,6 +238,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
assert.equal(hasExplicitCommand(appPing), true);
assert.equal(shouldStartApp(appPing), false);
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
@@ -280,6 +287,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
const markWatched = parseArgs(['--mark-watched']);
assert.equal(markWatched.markWatched, true);
assert.equal(hasExplicitCommand(markWatched), true);
assert.equal(shouldStartApp(markWatched), true);
assert.equal(commandNeedsOverlayRuntime(markWatched), true);
const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true);
+14
View File
@@ -28,6 +28,7 @@ export interface CliArgs {
triggerSubsync: boolean;
markAudioCard: boolean;
toggleStatsOverlay: boolean;
markWatched: boolean;
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
@@ -74,6 +75,7 @@ export interface CliArgs {
texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean;
appPing?: boolean;
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
@@ -133,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -172,6 +175,7 @@ export function parseArgs(argv: string[]): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
@@ -253,6 +257,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
else if (arg === '--mark-watched') args.markWatched = true;
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true;
@@ -339,6 +344,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--app-ping') args.appPing = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
@@ -506,6 +512,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -540,6 +547,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.appPing ||
args.update ||
args.generateConfig ||
args.help
@@ -579,6 +587,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -612,6 +621,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.appPing &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
@@ -643,6 +653,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -702,6 +713,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -737,6 +749,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.texthooker &&
!args.appPing &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
@@ -762,6 +775,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.updateLastCardFromClipboard ||
args.toggleSecondarySub ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
+1
View File
@@ -23,6 +23,7 @@ test('printHelp includes configured texthooker port', () => {
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--config\s+Open configuration window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);
+1
View File
@@ -39,6 +39,7 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode
--mark-watched Mark current video watched and advance playlist
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
@@ -0,0 +1,194 @@
import {
getNodeValue,
parseTree as parseJsoncTree,
type Node as JsoncNode,
type ParseError,
} from 'jsonc-parser';
import type { RawConfig } from '../types/config';
import type { ConfigSettingsPatchOperation } from '../types/settings';
import { DEFAULT_CONFIG } from './definitions';
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
export type LegacyAnkiConnectNPlusOneMigrationResult =
| {
migrated: true;
content: string;
rawConfig: RawConfig;
}
| {
migrated: false;
content: string;
rawConfig: RawConfig;
};
const LEGACY_N_PLUS_ONE_PATH_MAP = {
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
matchMode: 'ankiConnect.knownWords.matchMode',
decks: 'ankiConnect.knownWords.decks',
knownWord: 'subtitleStyle.knownWordColor',
nPlusOne: 'subtitleStyle.nPlusOneColor',
} as const;
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
return propertyNode?.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
return matches.at(-1);
}
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
return objectProperties(node).filter((property) => propertyKey(property) === key);
}
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
let node = root;
for (const segment of path.split('.')) {
node = propertyValue(findLastProperty(node, segment));
if (!node) return undefined;
}
return node;
}
function hasPath(root: JsoncNode | undefined, path: string): boolean {
return findValueAtPath(root, path) !== undefined;
}
function normalizeLegacyDecks(value: unknown): unknown {
if (!Array.isArray(value)) {
return value;
}
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
const decks = value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean);
const normalized: Record<string, string[]> = {};
for (const deck of new Set(decks)) {
normalized[deck] = defaultFields;
}
return normalized;
}
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
operations: ConfigSettingsPatchOperation[];
hasLegacy: boolean;
} {
const operations: ConfigSettingsPatchOperation[] = [];
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
if (nPlusOneObjects.length === 0) {
return { operations, hasLegacy: false };
}
const canonicalNPlusOneValues = new Map<string, unknown>();
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
let hasLegacy = false;
for (const nPlusOne of nPlusOneObjects) {
for (const property of objectProperties(nPlusOne)) {
const key = propertyKey(property);
if (!key) continue;
const valueNode = propertyValue(property);
const value = valueNode ? getNodeValue(valueNode) : undefined;
if (key === 'enabled' || key === 'minSentenceWords') {
canonicalNPlusOneValues.set(key, value);
continue;
}
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
hasLegacy = true;
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
}
}
}
if (nPlusOneObjects.length > 1) {
for (const [key, value] of canonicalNPlusOneValues) {
operations.push({
op: 'set',
path: `ankiConnect.nPlusOne.${key}`,
value,
});
}
}
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
>) {
if (!legacyValues.has(key)) continue;
if (!hasPath(root, path)) {
const value =
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
operations.push({
op: 'set',
path,
value,
});
}
operations.push({
op: 'reset',
path: `ankiConnect.nPlusOne.${key}`,
});
}
return { operations, hasLegacy };
}
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
content: string;
rawConfig: RawConfig;
}): LegacyAnkiConnectNPlusOneMigrationResult {
const errors: ParseError[] = [];
const root = parseJsoncTree(options.content || '{}', errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!root || errors.length > 0) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
if (operations.length === 0 && !hasLegacy) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const result = applyConfigSettingsPatchToContent({
content: options.content,
operations,
previousWarnings: [],
});
if (!result.ok) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
return {
migrated: true,
content: result.content,
rawConfig: result.rawConfig,
};
}
+309 -22
View File
@@ -12,16 +12,44 @@ import {
} from './definitions';
import { parseConfigContent } from './parse';
import { generateConfigTemplate } from './template';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
const DEFAULT_SUBTITLE_FONT_FAMILY =
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
const values: Record<string, unknown> = {
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
};
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
}
return buildSubtitleCssDeclarationObject(scope, values);
}
test('loads defaults when config is missing', () => {
const dir = makeTempDir();
const service = new ConfigService(dir);
@@ -74,7 +102,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.fontWeight, '600');
assert.equal(config.subtitleStyle.lineHeight, 1.35);
@@ -83,13 +111,19 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.fontKerning, 'normal');
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
assert.equal(config.subtitleStyle.paintOrder, '');
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
assert.deepEqual(config.subtitleSidebar.css, {});
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25);
@@ -113,6 +147,13 @@ test('loads defaults when config is missing', () => {
assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'system');
assert.equal(config.updates.channel, 'stable');
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
assert.equal(config.mpv.backend, 'auto');
assert.equal(config.mpv.autoStartSubMiner, true);
assert.equal(config.mpv.pauseUntilOverlayReady, true);
assert.equal(config.mpv.subminerBinaryPath, '');
assert.equal(config.mpv.aniskipEnabled, true);
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
});
test('parses updates config and warns on invalid values', () => {
@@ -181,6 +222,94 @@ test('throws actionable startup parse error for malformed config at construction
);
});
test('migrates legacy subtitle appearance options into css declaration objects on load', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
"hoverTokenColor": "#abcdef",
"hoverTokenBackgroundColor": "transparent",
"css": {
"font-size": "44px",
"text-wrap": "balance"
},
"secondary": {
"fontSize": 28,
"fontColor": "#bbbbbb"
}
},
"subtitleSidebar": {
"fontFamily": "M PLUS 1, sans-serif",
"fontSize": 18,
"textColor": "#dddddd",
"timestampColor": "#aaaaaa",
"css": {
"font-size": "19px"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
subtitleStyle: {
fontSize?: unknown;
fontColor?: unknown;
hoverTokenColor?: unknown;
hoverTokenBackgroundColor?: unknown;
css?: Record<string, string>;
secondary?: {
fontSize?: unknown;
fontColor?: unknown;
css?: Record<string, string>;
};
};
subtitleSidebar: {
fontFamily?: unknown;
fontSize?: unknown;
textColor?: unknown;
timestampColor?: unknown;
css?: Record<string, string>;
};
};
assert.deepEqual(parsed.subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
color: '#bbbbbb',
'font-size': '28px',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
assert.deepEqual(parsed.subtitleSidebar.css, {
'font-family': 'M PLUS 1, sans-serif',
color: '#dddddd',
'font-size': '19px',
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
});
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontFamily'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'textColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'timestampColor'), false);
assert.equal(service.getConfig().subtitleStyle.css['font-size'], '44px');
assert.equal(service.getConfig().subtitleStyle.secondary.css['font-size'], '28px');
assert.equal(service.getConfig().subtitleSidebar.css['font-size'], '19px');
});
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -255,6 +384,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
);
});
test('parses managed mpv plugin runtime settings from config', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"mpv": {
"socketPath": "/tmp/custom-subminer.sock",
"backend": "x11",
"autoStartSubMiner": false,
"pauseUntilOverlayReady": false,
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
"aniskipEnabled": false,
"aniskipButtonKey": "F8"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
const config = validService.getConfig();
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
assert.equal(config.mpv.backend, 'x11');
assert.equal(config.mpv.autoStartSubMiner, false);
assert.equal(config.mpv.pauseUntilOverlayReady, false);
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(config.mpv.aniskipEnabled, false);
assert.equal(config.mpv.aniskipButtonKey, 'F8');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"mpv": {
"socketPath": "",
"backend": "weston",
"autoStartSubMiner": "yes",
"pauseUntilOverlayReady": "no",
"subminerBinaryPath": 42,
"aniskipEnabled": "disabled",
"aniskipButtonKey": ""
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
const invalidConfig = invalidService.getConfig();
const warnings = invalidService.getWarnings();
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
});
test('parses annotationWebsocket settings and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -1685,6 +1878,7 @@ test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [
'anki.autoUpdateNewCards',
'subtitle.annotation.knownWords.highlightEnabled',
'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
@@ -1846,7 +2040,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
});
test('validates ankiConnect knownWords and n+1 color values', () => {
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
@@ -1867,16 +2061,17 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
configPath,
`{
"ankiConnect": {
"nPlusOne": {
@@ -1893,14 +2088,32 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne?: Record<string, unknown> };
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
});
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
test('legacy migration failures are logged and rethrown', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
const catchBlock = source.match(/catch\s*\(error\)\s*\{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
assert.ok(catchBlock);
assert.match(catchBlock, /legacy config migration failed/);
assert.match(catchBlock, /console\.error/);
assert.match(catchBlock, /throw error;/);
});
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
configPath,
`{
"ankiConnect": {
"nPlusOne": {
@@ -1918,25 +2131,70 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: {
knownWords: Record<string, unknown>;
nPlusOne?: Record<string, unknown>;
};
subtitleStyle: { knownWordColor?: string };
};
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
warning.path === 'ankiConnect.nPlusOne.decks' ||
warning.path === 'ankiConnect.nPlusOne.knownWord',
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
),
);
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
});
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
"minSentenceWords": 3
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": "3"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne: Record<string, unknown> };
};
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
@@ -1960,6 +2218,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok(
@@ -2280,9 +2539,9 @@ test('template generator includes known keys', () => {
assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"color": "#a6da95"/);
assert.match(output, /"knownWordColor": "#a6da95"/);
assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);
assert.match(output, /"nPlusOne": "#c6a0f6"/);
assert.match(output, /"minSentenceWords": 3/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(
@@ -2385,6 +2644,34 @@ test('template generator includes known keys', () => {
);
});
test('template generator uses settings CSS declaration paths for appearance fields', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleStyle.css'),
buildDefaultSubtitleCssDeclarations('primary'),
);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
buildDefaultSubtitleCssDeclarations('secondary'),
);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleSidebar.css'),
buildDefaultSubtitleCssDeclarations('sidebar'),
);
for (const scope of SUBTITLE_CSS_SCOPES) {
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
assert.equal(
getValueAtPath(parsed, path),
undefined,
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
);
}
}
});
test('template generator shows built-in default keybindings in the keybindings array', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output) as {
+1 -1
View File
@@ -124,5 +124,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
notificationType: 'system',
channel: 'stable',
},
auto_start_overlay: false,
auto_start_overlay: true,
};
@@ -1,4 +1,5 @@
import { ResolvedConfig } from '../../types/config';
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
@@ -59,7 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
addMinedWordsImmediately: true,
matchMode: 'headword',
decks: {},
color: '#a6da95',
},
behavior: {
overwriteAudio: true,
@@ -70,15 +70,15 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
autoUpdateNewCards: true,
},
nPlusOne: {
enabled: false,
minSentenceWords: 3,
nPlusOne: '#c6a0f6',
},
metadata: {
pattern: '[SubMiner] %f (%t)',
},
isLapis: {
enabled: false,
sentenceCardModel: 'Japanese sentences',
sentenceCardModel: 'Lapis',
},
isKiku: {
enabled: false,
@@ -94,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
mpv: {
executablePath: '',
launchMode: 'normal',
socketPath: getDefaultMpvSocketPath(),
backend: 'auto',
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '',
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
anilist: {
enabled: false,
+10 -3
View File
@@ -3,12 +3,13 @@ import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
subtitleStyle: {
primaryDefaultMode: 'visible',
css: {},
enableJlpt: false,
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: true,
nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
@@ -21,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
paintOrder: '',
WebkitTextStroke: '',
fontStyle: 'normal',
backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
@@ -43,7 +46,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
},
secondary: {
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
css: {},
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 24,
fontColor: '#cad3f5',
lineHeight: 1.35,
@@ -52,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
paintOrder: '',
WebkitTextStroke: '',
backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
fontWeight: '600',
@@ -65,11 +71,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
css: {},
maxWidth: 420,
opacity: 0.95,
backgroundColor: 'rgba(73, 77, 100, 0.9)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
+25 -2
View File
@@ -63,10 +63,9 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.jlptColors.N3',
'subtitleStyle.jlptColors.N4',
'subtitleStyle.jlptColors.N5',
'subtitleStyle.knownWordColor',
'subtitleStyle.letterSpacing',
'subtitleStyle.lineHeight',
'subtitleStyle.nPlusOneColor',
'subtitleStyle.paintOrder',
'subtitleStyle.secondary.backdropFilter',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.fontColor',
@@ -77,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.secondary.fontWeight',
'subtitleStyle.secondary.letterSpacing',
'subtitleStyle.secondary.lineHeight',
'subtitleStyle.secondary.paintOrder',
'subtitleStyle.secondary.textRendering',
'subtitleStyle.secondary.textShadow',
'subtitleStyle.secondary.WebkitTextStroke',
'subtitleStyle.secondary.wordSpacing',
'subtitleStyle.textRendering',
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
]);
@@ -103,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
'mpv.socketPath',
'mpv.backend',
'mpv.autoStartSubMiner',
'mpv.pauseUntilOverlayReady',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'yomitan.externalProfilePath',
'immersionTracking.enabled',
]) {
@@ -112,6 +121,20 @@ test('config option registry includes critical paths and has unique entries', ()
assert.equal(new Set(paths).size, paths.length);
});
test('known-word annotation color has one public config path', () => {
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
});
test('n+1 annotation color has one public config path', () => {
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
});
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
+2 -1
View File
@@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry(
path: 'auto_start_overlay',
kind: 'boolean',
defaultValue: defaultConfig.auto_start_overlay,
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
description:
'Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner.',
},
{
path: 'secondarySub.secondarySubLanguages',
+56 -13
View File
@@ -278,6 +278,13 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
description: 'Immediately append newly mined card words into the known-word cache.',
},
{
path: 'ankiConnect.nPlusOne.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
description:
'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number',
@@ -291,18 +298,6 @@ export function buildIntegrationConfigOptionRegistry(
description:
'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"] }.',
},
{
path: 'ankiConnect.nPlusOne.nPlusOne',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
description: 'Color used for the single N+1 target token highlight.',
},
{
path: 'ankiConnect.knownWords.color',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.knownWords.color,
description: 'Color used for known-word highlights.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',
kind: 'enum',
@@ -454,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.mpv.launchMode,
description: 'Default window state for SubMiner-managed mpv launches.',
},
{
path: 'mpv.socketPath',
kind: 'string',
defaultValue: defaultConfig.mpv.socketPath,
description:
'mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.',
},
{
path: 'mpv.backend',
kind: 'enum',
enumValues: ['auto', 'hyprland', 'sway', 'x11', 'macos', 'windows'],
defaultValue: defaultConfig.mpv.backend,
description:
'Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform.',
},
{
path: 'mpv.autoStartSubMiner',
kind: 'boolean',
defaultValue: defaultConfig.mpv.autoStartSubMiner,
description: 'Start SubMiner in the background when SubMiner-managed mpv loads a file.',
},
{
path: 'mpv.pauseUntilOverlayReady',
kind: 'boolean',
defaultValue: defaultConfig.mpv.pauseUntilOverlayReady,
description:
'Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness.',
},
{
path: 'mpv.subminerBinaryPath',
kind: 'string',
defaultValue: defaultConfig.mpv.subminerBinaryPath,
description:
'Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.',
},
{
path: 'mpv.aniskipEnabled',
kind: 'boolean',
defaultValue: defaultConfig.mpv.aniskipEnabled,
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
},
{
path: 'mpv.aniskipButtonKey',
kind: 'string',
defaultValue: defaultConfig.mpv.aniskipButtonKey,
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
@@ -567,7 +609,8 @@ export function buildIntegrationConfigOptionRegistry(
},
{
path: 'discordPresence.presenceStyle',
kind: 'string',
kind: 'enum',
enumValues: ['default', 'meme', 'japanese', 'minimal'],
defaultValue: defaultConfig.discordPresence.presenceStyle,
description:
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
},
{
path: 'subtitleStyle.css',
kind: 'object',
defaultValue: defaultConfig.subtitleStyle.css,
description:
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
},
{
path: 'subtitleStyle.secondary.css',
kind: 'object',
defaultValue: defaultConfig.subtitleStyle.secondary.css,
description:
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
},
{
path: 'subtitleStyle.enableJlpt',
kind: 'boolean',
@@ -69,6 +83,18 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
},
{
path: 'subtitleStyle.knownWordColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordColor,
description: 'Color used for known-word subtitle highlights.',
},
{
path: 'subtitleStyle.nPlusOneColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
description: 'Color used for the single N+1 target token subtitle highlight.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',
@@ -155,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
},
{
path: 'subtitleSidebar.css',
kind: 'object',
defaultValue: defaultConfig.subtitleSidebar.css,
description:
'CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.',
},
{
path: 'subtitleSidebar.maxWidth',
kind: 'number',
+18 -2
View File
@@ -20,9 +20,9 @@ export function buildRuntimeOptionRegistry(
}),
},
{
id: 'subtitle.annotation.nPlusOne',
id: 'subtitle.annotation.knownWords.highlightEnabled',
path: 'ankiConnect.knownWords.highlightEnabled',
label: 'N+1 Annotation',
label: 'Known Word Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
},
}),
},
{
id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.enabled',
label: 'N+1 Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
nPlusOne: {
enabled: value === true,
},
}),
},
{
id: 'subtitle.annotation.jlpt',
path: 'subtitleStyle.enableJlpt',
+11 -4
View File
@@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Overlay Auto-Start',
title: 'Visible Overlay Auto-Start',
description: [
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
'Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.',
'SubMiner can still auto-start in the background when this is false.',
],
key: 'auto_start_overlay',
},
@@ -32,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
key: 'logging',
},
{
@@ -90,6 +92,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Auto Subtitle Sync',
description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync',
},
{
@@ -126,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
@@ -135,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Jimaku',
description: ['Jimaku API configuration and defaults.'],
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku',
},
{
@@ -142,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
key: 'youtube',
},
{
@@ -166,7 +171,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'MPV Launcher',
description: [
'Optional mpv.exe override for Windows playback entry points.',
'SubMiner-managed mpv launch and bundled plugin options.',
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
],
+23
View File
@@ -84,6 +84,29 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
);
});
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
});
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
nPlusOne: { enabled: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, false);
});
test('converts legacy knownWords.decks array to object with default fields', () => {
const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck'] },
+31 -115
View File
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
const hasValidLegacyNPlusOneRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
if (hasValidLegacyNPlusOneRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
@@ -789,6 +747,23 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
context.resolved.ankiConnect.nPlusOne.enabled = true;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
@@ -813,12 +788,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyNPlusOneMatchMode =
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
@@ -832,25 +804,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyNPlusOneMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
@@ -882,7 +835,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
@@ -926,67 +878,31 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
} else if (Array.isArray(legacyNPlusOneDecks)) {
const normalized = legacyNPlusOneDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
);
}
}
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
if (nPlusOneHighlightColor !== undefined) {
context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor;
} else if (nPlusOneConfig.nPlusOne !== undefined) {
context.warn(
'ankiConnect.nPlusOne.nPlusOne',
nPlusOneConfig.nPlusOne,
context.resolved.ankiConnect.nPlusOne.nPlusOne,
'Expected a hex color value.',
);
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
? (context.src.subtitleStyle as Record<string, unknown>)
: {};
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const knownWordsColor = asColor(knownWordsConfig.color);
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
if (knownWordsColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
}
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.ankiConnect.knownWords.color,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
} else if (legacyNPlusOneKnownWordColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
DEFAULT_CONFIG.ankiConnect.knownWords.color,
'Legacy key is deprecated; use ankiConnect.knownWords.color',
);
} else if (nPlusOneConfig.knownWord !== undefined) {
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
context.resolved.ankiConnect.knownWords.color,
'Expected a hex color value.',
);
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
}
if (
+91
View File
@@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void {
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
);
}
const socketPath = asString(src.mpv.socketPath);
if (socketPath !== undefined && socketPath.trim().length > 0) {
resolved.mpv.socketPath = socketPath.trim();
} else if (src.mpv.socketPath !== undefined) {
warn(
'mpv.socketPath',
src.mpv.socketPath,
resolved.mpv.socketPath,
'Expected non-empty string.',
);
}
const backend = asString(src.mpv.backend);
if (
backend === 'auto' ||
backend === 'hyprland' ||
backend === 'sway' ||
backend === 'x11' ||
backend === 'macos' ||
backend === 'windows'
) {
resolved.mpv.backend = backend;
} else if (src.mpv.backend !== undefined) {
warn(
'mpv.backend',
src.mpv.backend,
resolved.mpv.backend,
'Expected auto, hyprland, sway, x11, macos, or windows.',
);
}
const autoStartSubMiner = asBoolean(src.mpv.autoStartSubMiner);
if (autoStartSubMiner !== undefined) {
resolved.mpv.autoStartSubMiner = autoStartSubMiner;
} else if (src.mpv.autoStartSubMiner !== undefined) {
warn(
'mpv.autoStartSubMiner',
src.mpv.autoStartSubMiner,
resolved.mpv.autoStartSubMiner,
'Expected boolean.',
);
}
const pauseUntilOverlayReady = asBoolean(src.mpv.pauseUntilOverlayReady);
if (pauseUntilOverlayReady !== undefined) {
resolved.mpv.pauseUntilOverlayReady = pauseUntilOverlayReady;
} else if (src.mpv.pauseUntilOverlayReady !== undefined) {
warn(
'mpv.pauseUntilOverlayReady',
src.mpv.pauseUntilOverlayReady,
resolved.mpv.pauseUntilOverlayReady,
'Expected boolean.',
);
}
const subminerBinaryPath = asString(src.mpv.subminerBinaryPath);
if (subminerBinaryPath !== undefined) {
resolved.mpv.subminerBinaryPath = subminerBinaryPath.trim();
} else if (src.mpv.subminerBinaryPath !== undefined) {
warn(
'mpv.subminerBinaryPath',
src.mpv.subminerBinaryPath,
resolved.mpv.subminerBinaryPath,
'Expected string.',
);
}
const aniskipEnabled = asBoolean(src.mpv.aniskipEnabled);
if (aniskipEnabled !== undefined) {
resolved.mpv.aniskipEnabled = aniskipEnabled;
} else if (src.mpv.aniskipEnabled !== undefined) {
warn(
'mpv.aniskipEnabled',
src.mpv.aniskipEnabled,
resolved.mpv.aniskipEnabled,
'Expected boolean.',
);
}
const aniskipButtonKey = asString(src.mpv.aniskipButtonKey);
if (aniskipButtonKey !== undefined && aniskipButtonKey.trim().length > 0) {
resolved.mpv.aniskipButtonKey = aniskipButtonKey.trim();
} else if (src.mpv.aniskipButtonKey !== undefined) {
warn(
'mpv.aniskipButtonKey',
src.mpv.aniskipButtonKey,
resolved.mpv.aniskipButtonKey,
'Expected non-empty string.',
);
}
} else if (src.mpv !== undefined) {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
}
+110
View File
@@ -10,6 +10,40 @@ import {
isObject,
} from './shared';
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
if (!isObject(value)) return undefined;
const declarations: Record<string, string> = {};
for (const [property, declarationValue] of Object.entries(value)) {
if (typeof declarationValue !== 'string') {
return undefined;
}
if (declarationValue.trim().length > 0) {
declarations[property] = declarationValue.trim();
}
}
return declarations;
}
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
function applySubtitleHoverTokenCssCompatibility(
subtitleStyle: ResolvedConfig['subtitleStyle'],
): void {
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
if (hoverTokenColor !== undefined) {
subtitleStyle.hoverTokenColor = hoverTokenColor;
}
const hoverTokenBackgroundColor = asCssColor(
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
);
if (hoverTokenBackgroundColor !== undefined) {
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
}
}
export function applySubtitleDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -157,6 +191,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
resolved.subtitleStyle.hoverTokenBackgroundColor;
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
const fallbackFrequencyDictionary = {
...resolved.subtitleStyle.frequencyDictionary,
};
@@ -209,6 +247,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
if (css !== undefined) {
resolved.subtitleStyle.css = css;
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
warn(
'subtitleStyle.css',
(src.subtitleStyle as { css?: unknown }).css,
resolved.subtitleStyle.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const rawSecondary = isObject(src.subtitleStyle.secondary)
? (src.subtitleStyle.secondary as { css?: unknown })
: undefined;
const secondaryCss = asCssDeclarations(rawSecondary?.css);
if (secondaryCss !== undefined) {
resolved.subtitleStyle.secondary.css = secondaryCss;
} else if (rawSecondary?.css !== undefined) {
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
warn(
'subtitleStyle.secondary.css',
rawSecondary.css,
resolved.subtitleStyle.secondary.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const preserveLineBreaks = asBoolean(
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
);
@@ -301,6 +368,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
const nameMatchColor = asColor(
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
);
@@ -333,6 +402,34 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const knownWordColor = asColor(
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
);
if (knownWordColor !== undefined) {
resolved.subtitleStyle.knownWordColor = knownWordColor;
} else if ((src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor !== undefined) {
resolved.subtitleStyle.knownWordColor = fallbackSubtitleStyleKnownWordColor;
warn(
'subtitleStyle.knownWordColor',
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
resolved.subtitleStyle.knownWordColor,
'Expected hex color.',
);
}
const nPlusOneColor = asColor((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor);
if (nPlusOneColor !== undefined) {
resolved.subtitleStyle.nPlusOneColor = nPlusOneColor;
} else if ((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor !== undefined) {
resolved.subtitleStyle.nPlusOneColor = fallbackSubtitleStyleNPlusOneColor;
warn(
'subtitleStyle.nPlusOneColor',
(src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor,
resolved.subtitleStyle.nPlusOneColor,
'Expected hex color.',
);
}
const frequencyDictionary = isObject(
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
)
@@ -445,6 +542,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
};
const css = asCssDeclarations((src.subtitleSidebar as { css?: unknown }).css);
if (css !== undefined) {
resolved.subtitleSidebar.css = css;
} else if ((src.subtitleSidebar as { css?: unknown }).css !== undefined) {
resolved.subtitleSidebar.css = fallback.css;
warn(
'subtitleSidebar.css',
(src.subtitleSidebar as { css?: unknown }).css,
resolved.subtitleSidebar.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
if (enabled !== undefined) {
resolved.subtitleSidebar.enabled = enabled;
@@ -55,6 +55,33 @@ test('subtitleSidebar accepts zero opacity', () => {
);
});
test('subtitleSidebar css declarations accept string declaration maps and warn on invalid values', () => {
const valid = createResolveContext({
subtitleSidebar: {
css: {
'font-size': '18px',
color: '#ffffff',
},
},
});
applySubtitleDomainConfig(valid.context);
assert.deepEqual(valid.context.resolved.subtitleSidebar.css, {
'font-size': '18px',
color: '#ffffff',
});
const invalid = createResolveContext({
subtitleSidebar: {
css: {
color: 42,
} as never,
},
});
applySubtitleDomainConfig(invalid.context);
assert.deepEqual(invalid.context.resolved.subtitleSidebar.css, {});
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleSidebar.css'));
});
test('subtitleSidebar falls back and warns on invalid values', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
+111
View File
@@ -28,6 +28,68 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
);
});
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
const valid = createResolveContext({
subtitleStyle: {
css: {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-hover-token-color': '#c6a0f6',
'--subtitle-hover-token-background-color': 'transparent',
},
secondary: {
css: {
'text-transform': 'uppercase',
},
},
},
});
applySubtitleDomainConfig(valid.context);
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-hover-token-color': '#c6a0f6',
'--subtitle-hover-token-background-color': 'transparent',
});
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
'text-transform': 'uppercase',
});
const invalid = createResolveContext({
subtitleStyle: {
css: {
'font-size': 42,
} as never,
secondary: {
css: 'font-size: 28px;' as never,
},
},
});
applySubtitleDomainConfig(invalid.context);
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
});
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
const { context } = createResolveContext({
subtitleStyle: {
css: {
'--subtitle-hover-token-color': 'purple',
'--subtitle-hover-token-background-color': '#363a4fd6',
},
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
});
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
const { context, warnings } = createResolveContext({
subtitleStyle: {
@@ -155,6 +217,55 @@ test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', (
);
});
test('subtitleStyle knownWordColor accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
knownWordColor: '#ed8796',
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.knownWordColor, '#ed8796');
const invalid = createResolveContext({
subtitleStyle: {
knownWordColor: 'pink',
},
});
applySubtitleDomainConfig(invalid.context);
assert.equal(invalid.context.resolved.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
invalid.warnings.some(
(warning) =>
warning.path === 'subtitleStyle.knownWordColor' &&
warning.message === 'Expected hex color.',
),
);
});
test('subtitleStyle nPlusOneColor accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
nPlusOneColor: '#ed8796',
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.nPlusOneColor, '#ed8796');
const invalid = createResolveContext({
subtitleStyle: {
nPlusOneColor: 'pink',
},
});
applySubtitleDomainConfig(invalid.context);
assert.equal(invalid.context.resolved.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.ok(
invalid.warnings.some(
(warning) =>
warning.path === 'subtitleStyle.nPlusOneColor' && warning.message === 'Expected hex color.',
),
);
});
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
+46 -3
View File
@@ -4,6 +4,8 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
import { resolveConfig } from './resolve';
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
export type ReloadConfigStrictResult =
| {
@@ -49,7 +51,10 @@ export class ConfigService {
if (!loadResult.ok) {
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
}
this.applyResolvedConfig(loadResult.config, loadResult.path);
this.applyResolvedConfig(
this.migrateLegacyConfig(loadResult.config, loadResult.path),
loadResult.path,
);
}
getConfigPath(): string {
@@ -70,7 +75,7 @@ export class ConfigService {
reloadConfig(): ResolvedConfig {
const { config, path: configPath } = loadRawConfig(this.configPaths);
return this.applyResolvedConfig(config, configPath);
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
}
reloadConfigStrict(): ReloadConfigStrictResult {
@@ -80,7 +85,10 @@ export class ConfigService {
}
const { config, path: configPath } = loadResult;
const resolvedConfig = this.applyResolvedConfig(config, configPath);
const resolvedConfig = this.applyResolvedConfig(
this.migrateLegacyConfig(config, configPath),
configPath,
);
return {
ok: true,
config: resolvedConfig,
@@ -113,4 +121,39 @@ export class ConfigService {
this.warnings = warnings;
return this.getConfig();
}
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
if (!fs.existsSync(configPath)) {
return config;
}
try {
let content = fs.readFileSync(configPath, 'utf-8');
let rawConfig = config;
let migrated = false;
for (const applyMigration of [
applyLegacyAnkiConnectNPlusOneMigrationToContent,
applyLegacySubtitleStyleCssMigrationToContent,
]) {
const migration = applyMigration({
content,
rawConfig,
});
if (!migration.migrated) {
continue;
}
content = migration.content;
rawConfig = migration.rawConfig;
migrated = true;
}
if (!migrated) {
return rawConfig;
}
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig;
} catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
throw error;
}
}
}
+64
View File
@@ -32,6 +32,70 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
assert.equal(parsed.subtitleStyle.fontSize, 35);
});
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": false
} /* old value */ ,
// effective value follows
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
+162 -2
View File
@@ -2,6 +2,9 @@ import {
applyEdits,
modify,
parse as parseJsonc,
parseTree as parseJsoncTree,
type Edit,
type Node as JsoncNode,
type FormattingOptions,
type ParseError,
} from 'jsonc-parser';
@@ -12,7 +15,7 @@ import type {
ConfigSettingsSnapshot,
} from '../../types/settings';
import { resolveConfig } from '../resolve';
import { getConfigValueAtPath } from './registry';
import { getConfigValueAtPath, SECRET_PATHS } from './registry';
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
insertSpaces: true,
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
}
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
content = removeDuplicatePropertiesAlongPath(content, operation.path);
const edits = modify(
content,
pathToSegments(operation.path),
@@ -103,6 +107,148 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
return applyEdits(content, edits);
}
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
return propertyNode.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function isWhitespace(value: string | undefined): boolean {
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
}
function nextNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index < content.length) {
if (isWhitespace(content[index])) {
index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '/') {
index += 2;
while (index < content.length && content[index] !== '\n') index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '*') {
index += 2;
while (
index + 1 < content.length &&
!(content[index] === '*' && content[index + 1] === '/')
) {
index += 1;
}
index = Math.min(content.length, index + 2);
continue;
}
break;
}
return index;
}
function previousNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index >= 0) {
if (isWhitespace(content[index])) {
index -= 1;
continue;
}
const lineStart = content.lastIndexOf('\n', index) + 1;
const linePrefix = content.slice(lineStart, index + 1);
const lineCommentStart = linePrefix.lastIndexOf('//');
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
index = lineStart - 1;
continue;
}
if (content[index] === '/' && content[index - 1] === '*') {
index -= 2;
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
index -= 1;
}
index -= 2;
continue;
}
break;
}
return index;
}
function lineStartOffset(content: string, offset: number): number {
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
}
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
let offset = propertyNode.offset;
let end = propertyNode.offset + propertyNode.length;
const next = nextNonWhitespaceOffset(content, end);
if (content[next] === ',') {
end = next + 1;
const lineStart = lineStartOffset(content, offset);
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
offset = lineStart;
}
} else {
const previous = previousNonWhitespaceOffset(content, offset - 1);
if (content[previous] === ',') {
offset = previous;
}
}
return {
offset,
length: Math.max(0, end - offset),
content: '',
};
}
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
const errors: ParseError[] = [];
let node = parseJsoncTree(content, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!node || errors.length > 0) {
return [];
}
const edits: Edit[] = [];
for (const segment of pathToSegments(path)) {
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
if (matches.length === 0) {
break;
}
for (const duplicate of matches.slice(0, -1)) {
edits.push(removalEditForProperty(content, duplicate));
}
node = propertyValue(matches[matches.length - 1]!);
}
return edits;
}
function applyRemovalEdits(content: string, edits: Edit[]): string {
return [...edits]
.sort((left, right) => right.offset - left.offset)
.reduce(
(current, edit) =>
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
content,
);
}
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
const edits = collectDuplicatePropertyRemovalEdits(content, path);
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
}
function collectModifiedWarnings(
warnings: ConfigValidationWarning[],
operations: ConfigSettingsPatchOperation[],
@@ -188,7 +334,21 @@ export function buildConfigSettingsSnapshot(
continue;
}
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
}
for (const secretPath of SECRET_PATHS) {
if (Object.hasOwn(values, secretPath)) {
continue;
}
const rawValue = getConfigValueAtPath(options.rawConfig, secretPath);
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, secretPath);
if (
(typeof rawValue === 'string' && rawValue.length > 0) ||
(typeof resolvedValue === 'string' && resolvedValue.length > 0)
) {
values[secretPath] = { configured: true };
}
}
return {
+218 -26
View File
@@ -1,39 +1,231 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG } from '../definitions';
import {
buildConfigSettingsRegistry,
getConfigSettingsCoverage,
LEGACY_HIDDEN_CONFIG_PATHS,
} from './registry';
import { buildConfigSettingsRegistry } from './registry';
test('config settings registry places hover pause under viewing playback behavior', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const hoverPause = fields.find(
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
function field(path: string) {
const match = fields.find((candidate) => candidate.configPath === path);
assert.ok(match, `missing settings field: ${path}`);
return match;
}
test('settings registry splits viewing into appearance and behavior categories', () => {
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
assert.equal(field('auto_start_overlay').category, 'behavior');
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
assert.equal(field('mpv.launchMode').category, 'behavior');
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
assert.ok(hoverPause);
assert.equal(hoverPause.category, 'viewing');
assert.equal(hoverPause.section, 'Playback pause behavior');
assert.equal(hoverPause.control, 'boolean');
});
test('config settings registry hides legacy and ignored paths from normal fields', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const visiblePaths = new Set(
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
test('settings registry groups annotation display fields by config group', () => {
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
});
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
assert.equal(
field('subtitleStyle.frequencyDictionary.sourcePath').section,
'Frequency Highlighting',
);
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
});
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
});
test('settings registry exposes specialized controls for config-assisted inputs', () => {
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
assert.equal(field('keybindings').control, 'mpv-keybindings');
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
assert.equal(field('stats.toggleKey').control, 'key-code');
assert.equal(field('discordPresence.presenceStyle').control, 'select');
});
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
const primaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
const secondaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.paintOrder').settingsHidden, true);
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.bandedColors').settingsHidden, false);
});
test('settings registry exposes css declaration editor for subtitle sidebar appearance', () => {
const sidebarVisible = fields
.filter(
(candidate) =>
candidate.section === 'Subtitle Sidebar Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(sidebarVisible, ['subtitleSidebar.css']);
assert.equal(field('subtitleSidebar.fontFamily').settingsHidden, true);
assert.equal(field('subtitleSidebar.fontSize').settingsHidden, true);
assert.equal(field('subtitleSidebar.textColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.timestampColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.hoverLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.enabled').settingsHidden, false);
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
});
test('settings registry routes playback-related integrations into integrations', () => {
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.defaultMode').category, 'integrations');
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
assert.ok(
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
);
assert.ok(
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
);
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
assert.equal(visiblePaths.has(path), false, path);
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
);
});
test('settings registry hides app-managed and inactive config surfaces', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
for (const hiddenPath of [
'ai.enabled',
'ai.apiKey',
'ai.apiKeyCommand',
'ai.model',
'ai.baseUrl',
'ai.systemPrompt',
'ai.requestTimeoutMs',
'ankiConnect.ai.enabled',
'ankiConnect.ai.model',
'ankiConnect.ai.systemPrompt',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
}
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
});
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
'jimaku.maxEntryResults',
'subsync.defaultMode',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path);
}
});
test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [
'stats.serverPort',
'ankiConnect.url',
'ankiConnect.proxy.enabled',
'mpv.socketPath',
'websocket.port',
]) {
assert.equal(field(path).restartBehavior, 'restart', path);
}
});
+406 -45
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
} from '../../settings/subtitle-style-css';
type Leaf = {
path: string;
@@ -46,41 +50,190 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.nPlusOne.matchMode',
'ankiConnect.nPlusOne.decks',
'ankiConnect.nPlusOne.knownWord',
'ankiConnect.nPlusOne.nPlusOne',
'ankiConnect.knownWords.color',
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'controller.buttonIndices',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set([
'Color',
'color',
'backgroundColor',
'singleColor',
'knownWordColor',
'nPlusOne',
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
...getSubtitleCssManagedConfigPaths('primary'),
...getSubtitleCssManagedConfigPaths('secondary'),
...getSubtitleCssManagedConfigPaths('sidebar'),
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
'appearance',
'behavior',
'mining-anki',
'input',
'integrations',
'tracking-app',
'advanced',
];
const SECTION_ORDER = new Map<string, number>(
[
'Annotation Display',
'Known Words',
'N+1',
'Frequency Highlighting',
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Pause Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'Visible Overlay Auto-Start',
'YouTube Playback Settings',
'MPV Launcher',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
'Anki AI',
'AnkiConnect',
'AnkiConnect Proxy',
'Jimaku',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
'Character Dictionary',
].map((section, index) => [section, index]),
);
const PATH_ORDER = new Map<string, number>(
[
'ankiConnect.enabled',
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css',
'subtitleStyle.primaryDefaultMode',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
'secondarySub.defaultMode',
'secondarySub.secondarySubLanguages',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
'mpv.socketPath',
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'mpv.launchMode',
'mpv.executablePath',
].map((path, index) => [path, index]),
);
const SUBSECTION_ORDER = new Map<string, number>(
[
'Known Words',
'N+1',
'JLPT',
'Frequency Highlighting',
'Character Names',
'Mining & Clipboard',
'Toggle & Visibility',
'Open Panels',
'Playback',
'Timing',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionary': 'Open AniList Override',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
'subtitleStyle.secondary.css': 'CSS Declarations',
'subtitleSidebar.css': 'CSS Declarations',
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
'subtitlePosition.yPercent': 'Subtitle Position',
'mpv.executablePath': 'mpv Executable Path',
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
'mpv.socketPath': 'mpv IPC Socket Path',
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
'ankiConnect.pollingRate':
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
'ankiConnect.isKiku.enabled':
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
'ankiConnect.isLapis.enabled':
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel':
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
'subtitleStyle.css':
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css':
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
@@ -119,6 +272,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
}
function humanizePath(path: string): string {
const override = LABEL_OVERRIDES[path];
if (override) {
return override;
}
const key = path.split('.').at(-1) ?? path;
const spaced = key
.replace(/_/g, ' ')
@@ -138,7 +295,29 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'viewing', section: 'Playback pause behavior' };
return { category: 'behavior', section: 'Playback Pause Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.refreshMinutes'
) {
return { category: 'behavior', section: 'Known Words' };
}
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
return { category: 'behavior', section: 'N+1' };
}
if (
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
path === 'subtitleStyle.frequencyDictionary.mode' ||
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
path === 'subtitleStyle.frequencyDictionary.topX'
) {
return { category: 'behavior', section: 'Frequency Highlighting' };
}
if (
path.startsWith('ankiConnect.knownWords.') ||
@@ -146,62 +325,80 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') ||
path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.knownWordColor' ||
path === 'subtitleStyle.nPlusOneColor' ||
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return { category: 'viewing', section: 'Annotation display' };
return { category: 'appearance', section: 'Annotation Display' };
}
if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'viewing', section: 'Secondary subtitle appearance' };
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
}
if (path === 'subtitleStyle.primaryDefaultMode') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('subtitleStyle.')) {
return { category: 'viewing', section: 'Primary subtitle appearance' };
return { category: 'appearance', section: 'Primary Subtitle Appearance' };
}
if (path.startsWith('subtitleSidebar.')) {
return { category: 'viewing', section: 'Subtitle sidebar' };
const sidebarBehaviorPaths = new Set([
'subtitleSidebar.enabled',
'subtitleSidebar.autoOpen',
'subtitleSidebar.autoScroll',
'subtitleSidebar.layout',
]);
return sidebarBehaviorPaths.has(path)
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
: { category: 'appearance', section: 'Subtitle Sidebar Appearance' };
}
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
return { category: 'viewing', section: 'Subtitle behavior' };
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('ankiConnect.fields.')) {
return { category: 'mining-anki', section: 'Note fields' };
return { category: 'mining-anki', section: 'Note Fields' };
}
if (path.startsWith('ankiConnect.media.')) {
return { category: 'mining-anki', section: 'Media capture' };
return { category: 'mining-anki', section: 'Media Capture' };
}
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
return { category: 'mining-anki', section: 'Kiku and Lapis' };
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
}
if (path.startsWith('ankiConnect.proxy.')) {
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
return { category: 'mining-anki', section: 'AnkiConnect Proxy' };
}
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (
path.startsWith('mpv.') ||
path.startsWith('youtube.') ||
path.startsWith('youtubeSubgen.') ||
path.startsWith('jimaku.') ||
path.startsWith('subsync.')
) {
return { category: 'playback-sources', section: topSection(path) };
if (path === 'auto_start_overlay') {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay shortcuts' };
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path === 'keybindings') {
return { category: 'input', section: 'MPV keybindings' };
return { category: 'input', section: 'MPV Keybindings' };
}
if (path.startsWith('controller.')) {
return { category: 'input', section: 'Controller' };
}
if (
path.startsWith('ai.') ||
path.startsWith('anilist.') ||
path.startsWith('yomitan.') ||
path.startsWith('jellyfin.') ||
path.startsWith('discordPresence.') ||
@@ -211,13 +408,18 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('anilist.characterDictionary.')) {
return { category: 'integrations', section: 'Character Dictionary' };
}
if (path.startsWith('anilist.')) {
return { category: 'integrations', section: 'AniList' };
}
if (
path.startsWith('immersionTracking.') ||
path.startsWith('stats.') ||
path.startsWith('updates.') ||
path.startsWith('startupWarmups.') ||
path.startsWith('logging.') ||
path === 'auto_start_overlay'
path.startsWith('logging.')
) {
return { category: 'tracking-app', section: topSection(path) };
}
@@ -235,23 +437,40 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv launcher',
mpv: 'MPV Launcher',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Auto subtitle sync',
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube playback',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Overlay startup',
auto_start_overlay: 'Visible Overlay Auto-Start',
};
return labels[top] ?? humanizePath(top);
}
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings';
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
if (path.startsWith('shortcuts.'))
return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut';
if (path === 'mpv.aniskipButtonKey') return 'mpv-key';
if (
path === 'subtitleSidebar.toggleKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey'
) {
return 'key-code';
}
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
if (Array.isArray(value)) return 'string-list';
@@ -266,6 +485,130 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
return 'json';
}
function subsectionForPath(path: string): string | undefined {
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
return 'JLPT';
}
if (
path === 'subtitleStyle.frequencyDictionary.enabled' ||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
) {
return 'Frequency Highlighting';
}
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
return 'Character Names';
}
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
return 'Default Fold State';
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
leaf === 'mineSentence' ||
leaf === 'mineSentenceMultiple' ||
leaf === 'updateLastCardFromClipboard' ||
leaf === 'triggerFieldGrouping' ||
leaf === 'markAudioCard'
) {
return 'Mining & Clipboard';
}
if (
leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched'
) {
return 'Toggle & Visibility';
}
if (
leaf === 'openCharacterDictionary' ||
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
) {
return 'Open Panels';
}
if (leaf === 'triggerSubsync') return 'Playback';
return undefined;
}
return undefined;
}
function isFeatureToggle(field: ConfigSettingsField): boolean {
if (field.control !== 'boolean') return false;
const leaf = field.configPath.split('.').at(-1) ?? field.configPath;
return (
leaf === 'enabled' ||
leaf.startsWith('enable') ||
leaf.endsWith('Enabled') ||
field.label.startsWith('Enable ')
);
}
function fieldTypeRank(field: ConfigSettingsField): number {
if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1;
}
function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
const category = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
if (category !== 0) return category;
const section =
(SECTION_ORDER.get(a.section) ?? Number.MAX_SAFE_INTEGER) -
(SECTION_ORDER.get(b.section) ?? Number.MAX_SAFE_INTEGER);
if (section !== 0) return section;
const sectionName = a.section.localeCompare(b.section);
if (sectionName !== 0) return sectionName;
const aSubOrder =
a.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
const bSubOrder =
b.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
const subsection = aSubOrder - bSubOrder;
if (subsection !== 0) return subsection;
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
if (subsectionName !== 0) return subsectionName;
const type = fieldTypeRank(a) - fieldTypeRank(b);
if (type !== 0) return type;
const pathOrder =
(PATH_ORDER.get(a.configPath) ?? Number.MAX_SAFE_INTEGER) -
(PATH_ORDER.get(b.configPath) ?? Number.MAX_SAFE_INTEGER);
if (pathOrder !== 0) return pathOrder;
const label = a.label.localeCompare(b.label);
if (label !== 0) return label;
return a.configPath.localeCompare(b.configPath);
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
@@ -273,7 +616,28 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai')
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.nPlusOne.enabled' ||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
path === 'ankiConnect.fields.word' ||
path === 'ankiConnect.fields.audio' ||
path === 'ankiConnect.fields.image' ||
path === 'ankiConnect.fields.sentence' ||
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
) {
return 'hot-reload';
}
@@ -283,13 +647,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description;
return {
id: leaf.path,
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
description: description ?? `${humanizePath(leaf.path)} setting.`,
configPath: leaf.path,
category,
section,
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
control: controlForPath(leaf.path, leaf.value),
defaultValue: leaf.value,
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
@@ -299,6 +665,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
}
@@ -306,13 +673,7 @@ export function buildConfigSettingsRegistry(
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
): ConfigSettingsField[] {
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
return leaves.map(fieldForLeaf).sort((a, b) => {
const category = a.category.localeCompare(b.category);
if (category !== 0) return category;
const section = a.section.localeCompare(b.section);
if (section !== 0) return section;
return a.configPath.localeCompare(b.configPath);
});
return leaves.map(fieldForLeaf).sort(compareFields);
}
export function getConfigSettingsCoverage(
+129
View File
@@ -0,0 +1,129 @@
import type { RawConfig } from '../types/config';
import type { ConfigSettingsPatchOperation } from '../types/settings';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export type LegacySubtitleStyleCssMigrationResult =
| {
migrated: true;
content: string;
rawConfig: RawConfig;
}
| {
migrated: false;
content: string;
rawConfig: RawConfig;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function hasPath(root: unknown, path: string): boolean {
let current = root;
const segments = path.split('.');
for (const [index, segment] of segments.entries()) {
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
return false;
}
if (index === segments.length - 1) {
return true;
}
current = current[segment];
}
return false;
}
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
if (path === 'subtitleStyle.hoverTokenColor') {
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
}
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
return typeof value === 'string';
}
return true;
}
export function buildLegacySubtitleStyleCssMigrationOperations(
rawConfig: RawConfig,
): ConfigSettingsPatchOperation[] {
const operations: ConfigSettingsPatchOperation[] = [];
for (const scope of SUBTITLE_CSS_SCOPES) {
const cssPath = getSubtitleCssPath(scope);
const values: Record<string, unknown> = {
[cssPath]: getValueAtPath(rawConfig, cssPath),
};
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
(legacyPath) =>
hasPath(rawConfig, legacyPath) &&
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
);
if (legacyPaths.length === 0) continue;
for (const legacyPath of legacyPaths) {
values[legacyPath] = getValueAtPath(rawConfig, legacyPath);
}
operations.push({
op: 'set',
path: cssPath,
value: buildSubtitleCssDeclarationObject(scope, values),
});
for (const legacyPath of legacyPaths) {
operations.push({ op: 'reset', path: legacyPath });
}
}
return operations;
}
export function applyLegacySubtitleStyleCssMigrationToContent(options: {
content: string;
rawConfig: RawConfig;
}): LegacySubtitleStyleCssMigrationResult {
const operations = buildLegacySubtitleStyleCssMigrationOperations(options.rawConfig);
if (operations.length === 0) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const result = applyConfigSettingsPatchToContent({
content: options.content,
operations,
previousWarnings: [],
});
if (!result.ok) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
return {
migrated: true,
content: result.content,
rawConfig: result.rawConfig,
};
}
+66
View File
@@ -6,11 +6,18 @@ import {
DEFAULT_KEYBINDINGS,
deepCloneConfig,
} from './definitions';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
);
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function normalizeCommentText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
function humanizeKey(key: string): string {
const spaced = key
.replace(/^--/, '')
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
return description;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function setValueAtPath(root: unknown, path: string, value: unknown): void {
const segments = path.split('.').filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
if (!isRecord(current)) return;
if (index === segments.length - 1) {
current[segment] = value;
return;
}
current = current[segment];
}
}
function deleteValueAtPath(root: unknown, path: string): void {
const segments = path.split('.').filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
if (!isRecord(current)) return;
if (index === segments.length - 1) {
delete current[segment];
return;
}
current = current[segment];
}
}
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
for (const scope of SUBTITLE_CSS_SCOPES) {
const cssPath = getSubtitleCssPath(scope);
const values: Record<string, unknown> = {
[cssPath]: getValueAtPath(templateConfig, cssPath),
};
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
for (const managedPath of managedPaths) {
values[managedPath] = getValueAtPath(templateConfig, managedPath);
}
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
for (const managedPath of managedPaths) {
deleteValueAtPath(templateConfig, managedPath);
}
}
}
function renderValue(value: unknown, indent = 0, path = ''): string {
const pad = ' '.repeat(indent);
const nextPad = ' '.repeat(indent + 2);
@@ -106,6 +171,7 @@ function renderSection(
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
const templateConfig = deepCloneConfig(config);
foldSubtitleCssManagedDefaults(templateConfig);
if (templateConfig.keybindings.length === 0) {
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
key: binding.key,
+87
View File
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -69,6 +70,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -91,6 +93,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
quitApp: () => {
calls.push('quitApp');
},
exitApp: (code) => {
calls.push(`exit:${code}`);
},
onSecondInstance: () => {},
handleCliCommand: () => {},
printHelp: () => {
@@ -136,3 +141,85 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
assert.equal(getLockCalls(), 1);
});
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
});
startAppLifecycle(makeArgs({ appPing: true }), deps);
assert.equal(getLockCalls(), 1);
assert.deepEqual(calls, ['exit:1']);
});
test('startAppLifecycle app ping exits zero immediately when another instance owns the lock', () => {
let lockCalls = 0;
const { deps, calls } = createDeps({
shouldStartApp: () => false,
requestSingleInstanceLock: () => {
lockCalls += 1;
return false;
},
});
startAppLifecycle(makeArgs({ appPing: true }), deps);
assert.equal(lockCalls, 1);
assert.deepEqual(calls, ['exit:0']);
});
test('startAppLifecycle queues second-instance commands until app ready runtime completes', async () => {
const handled: string[] = [];
let secondInstanceHandler: ((_event: unknown, argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
let releaseReady: (() => void) | null = null;
const readyFinished = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { deps } = createDeps({
shouldStartApp: () => true,
onSecondInstance: (handler) => {
secondInstanceHandler = handler;
},
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
startAppLifecycle(makeArgs({ background: true }), deps);
const runSecondInstance = (argv: string[]) => {
assert.ok(secondInstanceHandler);
(secondInstanceHandler as (_event: unknown, argv: string[]) => void)({}, argv);
};
const runReady = () => {
assert.ok(readyHandler);
return (readyHandler as () => Promise<void>)();
};
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, []);
const readyRun = runReady();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
+43 -1
View File
@@ -8,6 +8,7 @@ export interface AppLifecycleServiceDeps {
parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean;
quitApp: () => void;
exitApp: (code: number) => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
@@ -27,6 +28,7 @@ export interface AppLifecycleServiceDeps {
interface AppLike {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit?: (exitCode?: number) => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
}
@@ -54,6 +56,14 @@ export function createAppLifecycleDepsRuntime(
parseArgs: options.parseArgs,
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(),
exitApp: (code) => {
if (options.app.exit) {
options.app.exit(code);
return;
}
process.exitCode = code;
options.app.quit();
},
onSecondInstance: (handler) => {
options.app.on('second-instance', handler as (...args: unknown[]) => void);
},
@@ -94,14 +104,44 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
const gotTheLock = deps.requestSingleInstanceLock();
if (initialArgs.appPing) {
deps.exitApp(gotTheLock ? 1 : 0);
return;
}
if (!gotTheLock) {
deps.quitApp();
return;
}
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(args, 'second-instance');
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
const flushPendingSecondInstanceCommands = (): void => {
while (pendingSecondInstanceCommands.length > 0) {
const nextArgs = pendingSecondInstanceCommands.shift();
if (nextArgs) {
handleSecondInstanceCommand(nextArgs);
}
}
};
deps.onSecondInstance((_event, argv) => {
try {
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
pendingSecondInstanceCommands.push(nextArgs);
return;
}
handleSecondInstanceCommand(nextArgs);
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
@@ -119,6 +159,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
deps.whenReady(async () => {
await deps.onReady();
appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands();
});
deps.onWindowAllClosed(() => {

Some files were not shown because too many files have changed in this diff Show More