Compare commits

..

8 Commits

Author SHA1 Message Date
sudacode e58f56b46f Fix Yomitan popup shortcut precedence in keyboard-only mode
- In keyboard-driven mode, popup keys (e.g. `j`/`k`) now forward to the Yomitan popup instead of firing configured session bindings like `cycle sid`
- Non-keyboard-only mode behavior is unchanged
- Add regression test covering the `KeyJ`/`cycle sid` conflict
2026-05-03 20:29:06 -07:00
sudacode db30c61327 [codex] Fix Jellyfin setup and discovery toggle (#59) 2026-05-02 19:56:10 -07:00
sudacode 27f5b2bb58 Polish changelog fragments with claude -p at release time
- Replace `renderGroupedChanges` with `polishFragmentsWithClaude` that pipes fragments through `claude -p --model sonnet` to merge related items, drop housekeeping noise, and produce user-facing release notes
- Internal fragments kept in CHANGELOG.md under a `<details>` collapse; dropped from GitHub release notes entirely
- CI no longer auto-runs `changelog:build` on tag-based releases — fails fast with a clear error if `changes/*.md` fragments are still pending; build locally and commit before tagging
- Add `runClaude` dep-injection seam to test surface; add failure-mode coverage (missing binary, empty output, missing headers, missing `<details>` wrapper)
- Delete implemented design doc; update `changes/README.md` and `docs/RELEASING.md` with claude CLI prerequisite and new workflow
2026-05-02 19:52:48 -07:00
sudacode baabdb6d30 Add design doc for AI-polished changelog workflow
- Capture decisions from brainstorming: replace bullet renderer with `claude -p`, write straight to disk, hard-fail on missing/failed claude, drop internal section from release notes but keep collapsed in CHANGELOG.md
- Document prompt input/output contract, affected files, test plan, and CI guard that fails tag-based releases when changelog fragments are still pending
- Set scope boundaries (no caching, no SDK fallback, no `--no-polish` escape hatch)
2026-05-02 19:52:13 -07:00
sudacode 3a67e23bc3 feat: open texthooker from cli and tray 2026-05-02 19:37:44 -07:00
sudacode 13e2b5f8c8 Handle mpv reload buffering as same media
- Keep overlay alive across same-media mpv reloads
- Avoid rearming startup gate and repeating AniSkip lookups
- Add regression coverage for reload/end-file/file-loaded sequence
2026-05-02 15:42:54 -07:00
sudacode 53aa58d044 Route stats background mode through isolated daemon and defer in-app startup to live daemon (#58) 2026-04-26 19:26:01 -07:00
sudacode d8934647a9 Restore multi-copy digit capture and add AniList selection (#56) 2026-04-25 21:44:55 -07:00
192 changed files with 6818 additions and 538 deletions
+3 -4
View File
@@ -351,12 +351,11 @@ jobs:
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Build changelog artifacts for release
- name: Guard against pending changelog fragments
run: |
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
bun run changelog:build --version "${{ steps.version.outputs.VERSION }}"
else
echo "No pending changelog fragments found."
echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version ${{ steps.version.outputs.VERSION }}' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI."
exit 1
fi
- name: Verify changelog is ready for tagged release
+3 -1
View File
@@ -84,7 +84,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr>
<tr>
<td><b>Jellyfin</b></td>
<td>Browse and launch media from your Jellyfin server</td>
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr>
<tr>
<td><b>Jimaku</b></td>
@@ -252,6 +252,8 @@ subminer app --setup # launch the first-run setup wizard
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup.
Jellyfin setup is available from the tray or `subminer jellyfin`; once Jellyfin is enabled with a server URL, the tray can toggle Jellyfin Discovery for the current app session.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
@@ -0,0 +1,44 @@
---
id: TASK-314
title: Improve Jellyfin setup popup and tray discovery toggle
status: Done
assignee: []
created_date: '2026-05-02 22:45'
updated_date: '2026-05-02 23:11'
labels:
- jellyfin
dependencies: []
references:
- src/main/runtime/jellyfin-setup-window.ts
- src/main/runtime/jellyfin-cli-auth.ts
- src/main/runtime/tray-runtime.ts
- src/main/runtime/jellyfin-remote-session-lifecycle.ts
documentation:
- docs-site/jellyfin-integration.md
- docs-site/configuration.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Improve the Jellyfin integration setup experience and remove the need to use command-line discovery mode for normal tray-driven use. The existing `--jellyfin` setup popup should become a frontend for the same auth persistence path used by CLI login, with manual/recent server selection and inline feedback. The tray should expose a runtime-only Jellyfin Discovery checkbox when Jellyfin is configured so users can start or stop cast/discovery mode without changing config.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The Jellyfin setup popup supports config/recent/default server choices, manual URL entry, username/password login, logout when a session exists, done/close, and inline success/error status without persisting passwords.
- [x] #2 CLI login and setup popup login share the same auth persistence behavior, including encrypted token storage, enabled/server/username/client metadata config patching, and recent server updates.
- [x] #3 `jellyfin.recentServers` is parsed, normalized, deduplicated, capped, documented, and included in generated config examples if exposed.
- [x] #4 The tray keeps Configure Jellyfin visible and shows a Jellyfin Discovery checkbox only when Jellyfin is configured with enabled integration, server URL, access token, and user ID.
- [x] #5 The tray Jellyfin Discovery checkbox starts/stops the current remote session at runtime only, announces after start, reports OSD/log status, and does not patch config.
- [x] #6 Startup auto-connect behavior remains governed by existing config, including `remoteControlAutoConnect`; explicit tray start can start discovery without requiring `remoteControlAutoConnect`.
- [x] #7 Focused tests cover setup popup actions/rendering, shared auth persistence, config parsing, tray toggle visibility/state/click behavior, and remote lifecycle auto-connect versus explicit-start behavior.
- [x] #8 Jellyfin docs and changelog fragment are updated.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented Jellyfin setup popup improvements, shared auth persistence for CLI/setup config shape, recent server config support, runtime-only tray Jellyfin Discovery toggle, docs/config examples, and changelog fragment. Verified focused Jellyfin/tray tests, config tests, launcher tests, typecheck, and docs tests.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,43 @@
---
id: TASK-291
title: Add manual AniList selection for character dictionary resolution
status: Done
assignee:
- '@codex'
created_date: '2026-04-25 21:29'
updated_date: '2026-04-25 22:51'
labels:
- dictionary
- anilist
- cli
- ui
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add CLI and in-app UI support for correcting character dictionary anime resolution when guessit or AniList search picks the wrong series. Manual selections must apply to the whole detected series, persist across episodes, and replace stale incorrect entries in the auto-sync merged character dictionary state. Do not add tray UI. Known regression case: `Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv` previously resolved to `10607 - Rerere no Tensai Bakabon`; it should be correctable to the chosen Re:ZERO AniList media and then reused for later files in that series.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 CLI can show AniList candidate matches for the current/target media and set a manual character-dictionary AniList override.
- [x] #2 In-app UI can show the current character-dictionary match, candidate matches, and apply an override without adding tray controls.
- [x] #3 Persisted overrides are keyed at series scope so all later episodes in the same series reuse the selected AniList media.
- [x] #4 Applying an override clears stale guess state, replaces the old incorrect active media entry in auto-sync state, rebuilds/imports the merged character dictionary, and refreshes subtitle dictionary usage.
- [x] #5 Regression tests cover the Re:ZERO filename, override reuse, stale active-media replacement, CLI handling, and IPC/UI contract behavior.
- [x] #6 Docs are updated for manual character dictionary anime selection.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused override/resolution layer for character dictionary media selection: derive a stable series key from filename/guessit data, persist manual AniList media overrides under user data, and expose AniList candidate search helpers.
2. Update character dictionary snapshot resolution to check manual overrides before guessit-derived AniList search, and update auto-sync so applying an override removes stale incorrect media IDs and rebuilds/imports the merged dictionary.
3. Extend CLI with commands to list candidates and set an override for current or target media.
4. Extend existing in-app settings UI via IPC/preload contracts: show current match/candidates and let user apply an override. No tray controls.
5. Use TDD: add failing regressions first for Re:ZERO parsing/override behavior, auto-sync replacement, CLI handling, IPC contract, and UI state; then implement.
6. Update docs-site/manual docs for manual character dictionary anime selection, launcher usage, and the default `Ctrl+Alt+A` modal shortcut, then run focused tests and broader gates as time permits.
<!-- SECTION:PLAN:END -->
@@ -0,0 +1,52 @@
---
id: TASK-292
title: Restore Linux multi-subtitle copy digit capture
status: Done
assignee:
- '@codex'
created_date: '2026-04-25 21:31'
updated_date: '2026-04-25 21:36'
labels:
- bug
- linux
- shortcuts
- clipboard
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
On Linux, the copy-subtitle-multiple shortcut opens the numeric prompt but the follow-up digit is not captured, so the flow times out. User confirmed `wl-copy` itself is installed and working, so investigate the shortcut/digit capture path and restore multi-line subtitle copy without regressing existing session action behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Linux copy-subtitle-multiple shortcut accepts a follow-up digit and copies that number of recent subtitle lines instead of timing out.
- [x] #2 The fix avoids depending on Linux Electron global shortcut digit registration for the follow-up numeric selection when a renderer-visible session can handle it.
- [x] #3 Regression tests cover the Linux multi-copy shortcut/digit flow and existing non-Linux/global shortcut behavior remains intact.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing regression coverage for Linux copy-subtitle-multiple local shortcut fallback starting renderer/session numeric selection instead of main-process digit globalShortcut capture.
2. Patch the overlay shortcut fallback/runtime path so Linux visible-overlay multi-copy and mine-sentence-multiple can dispatch session-action numeric selection when renderer handling is available, while preserving main-process numeric sessions for CLI/non-renderer paths.
3. Run targeted tests for shortcut fallback, overlay runtime, and renderer keyboard numeric selection; then run typecheck or a wider focused gate if needed.
4. Update task acceptance criteria/final notes after verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented the approved path by keeping multi-step numeric overlay shortcuts out of the main-process local fallback. The visible overlay now receives the original keydown and uses the existing renderer/session-action numeric selection flow for follow-up digits, avoiding Linux Electron globalShortcut digit capture for multi-copy and mine-sentence-multiple. Verification: targeted shortcut/renderer tests and changelog lint pass. `bun run typecheck` is currently blocked by unrelated existing errors in CLI/AniList dictionary-candidate work and `src/main/dependencies.ts` manual-selection API shape.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored Linux multi-line subtitle copy by preventing main-process overlay shortcut fallback from consuming multi-step numeric shortcuts (`copySubtitleMultiple` and `mineSentenceMultiple`). Those shortcuts now fall through to the visible overlay renderer, where the existing session binding flow prompts for a digit and dispatches the counted session action locally instead of relying on Electron globalShortcut digit registration. Added regression coverage for the fallback behavior and renderer follow-up digit dispatch, plus a changelog fragment.
Verification: `bun test src/core/services/overlay-shortcut-handler.test.ts src/renderer/handlers/keyboard.test.ts`; `bun run changelog:lint`. Full `bun run typecheck` was attempted but is blocked by unrelated current worktree errors in CLI/AniList dictionary-candidate tests/types and `src/main/dependencies.ts`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,25 @@
---
id: TASK-293
title: Fix interjection tokens receiving subtitle annotations
status: In Progress
assignee: []
created_date: '2026-04-25 22:50'
labels:
- tokenizer
- bug
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Standalone interjections such as あ should remain hoverable dictionary tokens but must not receive N+1, frequency, JLPT, or known-word subtitle annotation metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A MeCab 感動詞 token like あ is excluded by the shared subtitle annotation gate.
- [ ] #2 annotateTokens strips N+1/frequency/JLPT/known metadata from the interjection while preserving token lookup fields.
- [ ] #3 Focused tokenizer regression passes.
<!-- AC:END -->
@@ -0,0 +1,32 @@
---
id: TASK-294
title: Fix annotated subtitle tokens to honor subtitleStyle
status: Done
assignee: []
created_date: '2026-04-25 23:04'
updated_date: '2026-04-25 23:07'
labels:
- subtitles
- renderer
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Annotated token spans should inherit the configured subtitleStyle typography and only use annotation metadata to change token color.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tokenized/annotated subtitles preserve configured base subtitle typography such as font family, size, weight, line height, letter spacing, text rendering, and text shadow.
- [x] #2 Known/N+1/JLPT/frequency/name-match annotations affect token color only, plus existing token metadata/hover affordances.
- [x] #3 A renderer regression test covers annotated token rendering with custom subtitleStyle.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated renderer subtitle annotation CSS so known/N+1/name/JLPT/frequency classes no longer override typography with token-specific shadows, underlines, padding, or hover font-weight. Added regression coverage using the user's custom subtitleStyle shape to verify annotated token spans inherit base typography and annotation CSS changes token color only. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,66 @@
---
id: TASK-295
title: Add primary subtitle visibility keybinding
status: Done
assignee:
- Codex
created_date: '2026-04-25 23:09'
updated_date: '2026-04-25 23:45'
labels:
- renderer
- keybindings
- subtitles
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a `v` keybinding that overrides mpv's default `v` subtitle visibility toggle and instead toggles SubMiner's primary subtitle bar visibility on and off. Secondary subtitle hover behavior is out of scope.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Pressing `v` toggles the primary subtitle bar from visible to hidden.
- [x] #2 Pressing `v` again restores the primary subtitle bar visibility.
- [x] #3 The keybinding does not add or change secondary subtitle hover behavior.
- [x] #4 Relevant automated coverage verifies the toggle behavior.
- [x] #5 Pressing `v` in the mpv/plugin keybinding path also toggles the primary subtitle bar visibility instead of mpv native subtitle visibility.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect existing renderer keybinding and subtitle bar visibility code, including current local edits in touched files.
2. Add a focused failing test for `v` toggling primary subtitle bar visibility without changing secondary hover behavior.
3. Implement the minimal renderer/keybinding change.
4. Run targeted tests and update acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented renderer-local `KeyV` handling before session/mpv binding dispatch so mpv `sub-visibility` is not touched. Visibility state is stored in renderer state and applied via `primary-sub-hidden` class on the primary subtitle container.
Scope updated after user clarified the toggle must work when focus is in mpv as well as in the overlay renderer. Added a forced mpv plugin binding for `v` that runs `--toggle-primary-subtitle-bar`, then broadcasts a renderer IPC toggle event and reuses the same primary subtitle bar toggle path.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added a renderer-local `v` key handler that toggles primary subtitle bar visibility by adding/removing `primary-sub-hidden` on the primary subtitle container.
- Added renderer state for the toggle so repeated presses restore the bar without issuing mpv `sub-visibility` commands.
- Added a forced mpv plugin `v` binding that invokes `--toggle-primary-subtitle-bar` and broadcasts the same renderer toggle event.
- Added CSS for the hidden primary subtitle bar state and regression coverage for both overlay and mpv/plugin entry points.
Tests:
- `bun test src/renderer/handlers/keyboard.test.ts --test-name-pattern "primary subtitle visibility key"`
- `bun test src/cli/args.test.ts --test-name-pattern "session action"`
- `bun test src/core/services/cli-command.test.ts --test-name-pattern "visibility and utility"`
- `bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/cli-command-context-deps.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-context-factory.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/first-run-setup-service.test.ts`
- `lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-lua-compat.lua`
- `bun run typecheck`
- `bun run test:fast`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-296
title: Suppress crash notification when closing launcher-managed video
status: Done
assignee: []
created_date: '2026-04-25 23:12'
updated_date: '2026-04-26 02:44'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix regression where closing a running mpv video causes SubMiner/Electron service crash notification (`<html><tt>/SubMiner</tt> has encountered a fatal error and was closed.</html>`). Not present on origin/main/v0.12.0 path.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Closing a launcher-managed video stops the overlay/app without desktop crash notification.
- [x] #2 Regression test covers the shutdown path that caused the notification.
- [x] #3 Relevant launcher/app tests pass.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Confirm notification source from local crash metadata and mpv/app logs.
2. Add regression coverage for mpv quit/shutdown lifecycle helper spawning.
3. Update mpv Lua lifecycle to avoid Electron helper subprocesses during quit/shutdown while preserving normal end-file hide behavior.
4. Run plugin tests and changelog lint.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Crash records in ~/.cache/drkonqi show SIGBUS in Electron NetworkService utility process for SubMiner AppImage. mpv log shows shutdown starts two `/home/sudacode/.local/bin/SubMiner.AppImage --hide-visible-overlay` subprocesses and kills them during close. Root cause is mpv plugin spawning Electron control helpers during quit/shutdown.
Follow-up after retest: installed plugin matched source patch and no close-time hide command was spawned. New mpv log shows the initial `/home/sudacode/.local/bin/SubMiner.AppImage --start ...` subprocess remains owned by mpv for the whole playback and is killed when mpv quits. New DrKonqi crash at 2026-04-25 16:44 again shows SIGBUS in Electron NetworkService from that AppImage mount. Need detach the long-lived plugin-launched `--start` app process from mpv.
Second fix: plugin-launched `--start` now includes `--background`, using SubMiner's existing background relaunch path so mpv owns only a short-lived starter process rather than the long-running Electron app. Ran `make install-plugin` so ~/.config/mpv/scripts/subminer now contains both lifecycle and background-start fixes. Full `scripts/test-plugin-start-gate.lua` is currently blocked by an unrelated dirty-worktree primary subtitle bar binding test from TASK-297.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Changed the mpv Lua lifecycle so `shutdown` no longer spawns a `--hide-visible-overlay` helper, and `end-file` skips that helper when mpv reports `reason = "quit"`. Also changed plugin-launched `--start` to include `--background`, so mpv owns only SubMiner's short background launcher process instead of the long-running Electron/AppImage process. This addresses both observed crash sources: close-time helper commands and mpv killing the main SubMiner child process at quit. Installed the updated plugin into `~/.config/mpv/scripts/subminer` with `make install-plugin`, and the user confirmed the latest close no longer produced the notification.
Tests: `lua scripts/test-plugin-start-gate.lua` initially proved the shutdown regression failed before the lifecycle fix; full start-gate is currently affected by other dirty work in this file. Passing checks for this commit: `lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-binary-windows.lua`; `bun run changelog:lint`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,32 @@
---
id: TASK-297
title: Fix subtitle annotation color priority after typography cleanup
status: Done
assignee: []
created_date: '2026-04-25 23:44'
updated_date: '2026-04-25 23:46'
labels:
- subtitles
- renderer
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Known-word and frequency subtitle token colors should keep their configured priority after annotation CSS stopped using JLPT underlines.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Known-word token color takes priority over JLPT and frequency color classes.
- [x] #2 Frequency single-mode token color takes priority over JLPT color classes when frequency annotation is active.
- [x] #3 Regression coverage verifies CSS selectors do not allow JLPT color rules to override higher-priority annotation colors.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Scoped JLPT token color selectors so they only apply when no higher-priority known-word, N+1, name-match, or frequency class is present. This keeps known words green and frequency single-mode tokens using the single frequency color instead of being visually overridden by JLPT colors. Added CSS regression assertions for this priority behavior. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-298
title: Exclude kana grammar-helper merges like ことに from subtitle annotations
status: Done
assignee:
- codex
created_date: '2026-04-26 00:08'
updated_date: '2026-04-26 00:15'
labels:
- tokenizer
- annotations
- bug
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix subtitle tokenizer annotation behavior where all-hiragana grammar-helper merged tokens such as `ことに` can be marked as N+1. Current likely path: Yomitan emits `ことに` with headword `こと`; MeCab enrichment supplies content-led POS (`名詞|助詞`, likely `非自立|格助詞`); shared subtitle annotation filter does not exclude this family unless it matches narrower rules such as `これで` or explanatory endings.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `ことに`-style kana grammar-helper merges are not marked known, N+1, JLPT, or frequency-highlighted when their MeCab metadata indicates a non-independent noun plus helper particle.
- [x] #2 Regression coverage demonstrates the reported subtitle phrase does not mark `ことに` as N+1 while preserving annotation for real lexical content tokens.
- [x] #3 Existing tokenizer annotation tests pass.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Approved approach (user: "let's do it"):
1. Add a regression test for the reported `ことに` case using Yomitan token `ことに` -> headword `こと` and MeCab metadata `名詞|助詞` / `非自立|格助詞`; assert all annotation fields are stripped while nearby lexical content can still be N+1.
2. Verify the new test fails before production changes.
3. Update the shared subtitle annotation filter to exclude conservative kana-only grammar-helper merges: merged surface differs from headword, surface is kana-only, first POS component is `名詞`, first POS2 component is `非自立`, and remaining POS components are grammar helpers (`助詞`/`助動詞`).
4. Run targeted tokenizer/annotation tests and update the task acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Red test initially passed with headword `こと` because `こと` is already in `JLPT_EXCLUDED_TERMS` and the shared subtitle annotation filter checks that set. Updated regression to the live-risk shape `surface=ことに`, `headword=事`, with MeCab POS `名詞|助詞` / `非自立|格助詞`; this failed before the filter change and passed after.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented a conservative shared subtitle annotation filter for kana-only non-independent noun helper merges. Tokens such as `ことに` with a kanji dictionary headword like `事` are now stripped of known-word, N+1, JLPT, and frequency metadata when MeCab shows the first component as `名詞/非自立` and trailing components as grammar helpers.
Added unit coverage in `src/core/services/tokenizer/annotation-stage.test.ts` and an integration-style tokenizer regression for the reported phrase shape in `src/core/services/tokenizer.test.ts`, verifying `ことに` stays plain while a real lexical token can still become the N+1 target.
Validation: `bun test src/core/services/tokenizer/annotation-stage.test.ts`; `bun test src/core/services/tokenizer.test.ts`; `bun run test:fast`; `bun run changelog:lint`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,66 @@
---
id: TASK-299
title: Force audio replacement during manual subtitle mining
status: Done
assignee:
- Codex
created_date: '2026-04-26 00:10'
updated_date: '2026-04-26 02:42'
labels:
- anki
- mining
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Manual subtitle mining via the Ctrl+C/Ctrl+V flow should replace expression and sentence audio fields even when the user has configured media overwrite fields to false. These fields can already contain proxy-inserted SubMiner audio on a new card, and manual update intent is to replace that generated content.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Manual subtitle mining replaces existing expression audio content regardless of configured audio overwrite settings.
- [x] #2 Manual subtitle mining replaces existing sentence audio content regardless of configured audio overwrite settings.
- [x] #3 Non-manual mining/update flows continue to respect configured audio overwrite settings.
- [x] #4 A regression test covers manual audio replacement when overwrite settings are disabled.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Locate the manual subtitle mining Ctrl+C/Ctrl+V flow and the Anki media field overwrite gate.
2. Add a failing regression test showing manual mining overwrites expression and sentence audio when configured audio overwrite is disabled.
3. Implement the smallest path-specific override so only manual subtitle mining forces audio replacement.
4. Run the focused mining test and update task acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented focused manual clipboard update behavior in CardCreationService.updateLastAddedFromClipboard: generated manual audio is written to both resolved sentence audio and expression audio fields with forced overwrite. Other update flows still use existing overwrite config paths.
Verification: focused Anki tests passed; typecheck passed; changelog lint and diff check passed. Full bun run test:fast was attempted but is blocked by unrelated existing tokenizer annotation-stage failures tied to dirty task 298 worktree changes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Manual clipboard subtitle updates now resolve both sentence audio and expression audio fields and replace both with the newly generated audio regardless of ankiConnect.behavior.overwriteAudio.
- Added a regression test for the Ctrl+C/Ctrl+V manual update path with existing proxy-inserted audio and overwriteAudio disabled.
- Registered the regression test in test:fast, documented the overwrite exception in user docs, and added a changelog fragment.
Verification:
- bun test src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation.test.ts
- bun run tsc --noEmit
- bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts
- bun run changelog:lint
- bun run docs:test
- bun run docs:build
- git diff --check
Blocked gate:
- bun run test:fast currently fails in unrelated src/core/services/tokenizer/annotation-stage.test.ts tests for kana-only non-independent noun helper merges; those files have pre-existing dirty changes outside this task.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,51 @@
---
id: TASK-300
title: Fix transparent subtitle hover background config
status: Done
assignee: []
created_date: '2026-04-26 03:23'
updated_date: '2026-04-26 03:26'
labels:
- bug
- overlay
- config
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
User reports setting subtitleStyle.hoverTokenBackgroundColor to transparent still renders default hover background in overlay subtitles.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Transparent hoverTokenBackgroundColor is accepted by config resolution.
- [x] #2 Renderer applies transparent hover token background instead of falling back to default.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce config alias behavior with a failing config test.
2. Map subtitleStyle.hoverBackground to hoverTokenBackgroundColor in config resolution while keeping canonical key precedence.
3. Add renderer regression for transparent hover token background CSS variable.
4. Update docs and changelog fragment; run focused verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Local user config used subtitleStyle.hoverBackground, which was ignored because only subtitleStyle.hoverTokenBackgroundColor was recognized. Canonical key still takes precedence when both are present.
Verification passed: bun run test:config:src; bun test src/renderer/subtitle-render.test.ts; bun run changelog:lint; bun run docs:test; bun run docs:build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented config compatibility for transparent hover token backgrounds. `subtitleStyle.hoverBackground` now maps to the canonical `subtitleStyle.hoverTokenBackgroundColor` during resolution, preserving canonical key precedence. Added regression coverage for the alias and renderer handling of `transparent`, documented the alias, and added a changelog fragment.
Verification: `bun run test:config:src`; `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`; `bun run docs:test`; `bun run docs:build`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,55 @@
---
id: TASK-301
title: Fix launcher-managed video close leaving background app alive
status: Done
assignee:
- Codex
created_date: '2026-04-26 03:29'
updated_date: '2026-04-26 03:44'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Launcher/plugin-managed video playback should not leave the SubMiner background app or tray icon running after the video closes unless the user explicitly launched SubMiner in background mode via --background or by starting with no app arguments. This is a regression after crash-avoidance work that added background startup for launcher-managed playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Closing a launcher-managed video exits the launcher-started SubMiner app/tray instead of leaving it alive.
- [x] #2 Explicit background launches still keep SubMiner alive after windows close.
- [x] #3 No-argument app startup behavior remains unchanged.
- [x] #4 Regression coverage exercises the launcher-managed playback shutdown lifecycle.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression coverage first: plugin auto-start should tag launcher-managed playback, and app mpv shutdown handling should quit only when started in that managed playback mode.
2. Add a narrow CLI flag/state field for launcher-managed playback, separate from explicit persistent background mode.
3. Have plugin pass the new flag with its background start command.
4. On mpv shutdown/disconnect, request app quit only when managed playback mode is active; preserve explicit --background and no-arg startup persistence.
5. Run focused plugin/app tests, then relevant launcher/core gates if feasible.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented managed playback shutdown by adding a `--managed-playback` app flag that the mpv plugin passes only for launcher-managed starts. The main mpv shutdown path now quits the app when initial args indicate managed playback, while explicit background/no-arg startup remains persistent. Added plugin start-gate and mpv protocol regression coverage.
Implemented managed playback lifecycle: mpv plugin auto-start passes --background --managed-playback; app quits on mpv shutdown only when initial args include managedPlayback. Explicit --background and no-arg startup remain persistent. Installed updated mpv plugin to ~/.config/mpv/scripts/subminer via make install-plugin.
Retest showed tray still remained. Root cause: relying on mpv's JSON IPC shutdown event was insufficient; the app may only see the socket close. Added managed-playback quit on MpvIpcClient onClose before reconnect scheduling, with regression coverage.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Launcher-managed playback now starts SubMiner with an internal --managed-playback marker alongside --background. The app requests quit either when mpv sends shutdown or when the mpv IPC socket closes, but only for managed playback mode; explicit background/no-arg startup remains persistent. Added CLI, mpv protocol, mpv socket-close, and plugin regression coverage plus a launcher changelog fragment. Rebuilt the app/launcher and confirmed focused checks, typecheck, build, plugin tests, dist smoke, and formatting.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,53 @@
---
id: TASK-302
title: Add visible hover affordance for annotated subtitle tokens
status: Done
assignee: []
created_date: '2026-04-26 03:39'
updated_date: '2026-04-26 03:40'
labels:
- overlay
- subtitle
- ux
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Annotated subtitle tokens keep annotation colors on hover, but with transparent hover backgrounds there is no visible hover indication. Add a subtle affordance that preserves annotation color semantics.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Annotated token hover keeps annotation color instead of switching to hover text color.
- [x] #2 Annotated token hover has a visible indication when hover background is transparent.
- [x] #3 Regression tests cover the hover CSS contract.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing CSS contract test for annotated token hover affordance.
2. Add brightness/saturation filter to annotated token hover CSS without changing annotation color.
3. Add changelog fragment and run focused verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented option 1 from approved design: annotated subtitle word hover keeps annotation color and adds `filter: brightness(1.18) saturate(1.08)` for visible affordance when the hover background is transparent.
Verification passed: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added a visible hover affordance for annotated subtitle tokens without changing annotation color semantics. Annotated word hover now applies a small brightness/saturation lift while retaining the existing background behavior, so transparent hover backgrounds still show feedback.
Regression coverage updated in `src/renderer/subtitle-render.test.ts` to assert the hover filter is present and that hover color overrides are still absent for annotated tokens. Added changelog fragment `changes/302-annotated-hover-affordance.md`.
Verification: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,57 @@
---
id: TASK-303
title: Update tray menu help action
status: Done
assignee:
- Codex
created_date: '2026-04-26 03:54'
updated_date: '2026-04-26 04:12'
labels:
- tray
- overlay
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the tray menu's direct visible-overlay open action with an action that opens the existing in-session help modal. The tray should no longer expose an "Open Overlay" menu item; users should be able to open help from the tray instead.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tray menu no longer includes an "Open Overlay" option.
- [x] #2 Tray menu includes an option to open the session help modal.
- [x] #3 Selecting the new tray help option initializes overlay runtime if needed and invokes the existing session help modal path.
- [x] #4 Focused regression tests cover the menu label and action wiring.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused regression coverage for tray menu template labels and main-process action wiring: assert Open Overlay is absent, Open Help is present, and clicking help initializes overlay runtime if needed before opening the existing session help modal path.
2. Update tray runtime action types/template to replace openOverlay with openSessionHelp.
3. Update tray main action builder dependencies to call the existing openSessionHelpModal function after overlay runtime initialization.
4. Run targeted tray tests, then broader relevant fast tests if needed.
5. Check acceptance criteria and finalize backlog notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented tray menu replacement via existing session help overlay path. Verification passed: targeted tray tests (`bun test 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`), SubMiner verifier lanes `runtime-compat` and `docs`, and `bun run changelog:lint`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Replaced the tray menu's `Open Overlay` item with `Open Help`, wired it to initialize overlay runtime when needed, and then open the existing session help modal path. Updated tray runtime/main-deps/action tests to assert the old label is absent, the new label is present, and the new action calls the help modal. Added changelog fragment `changes/303-tray-help-menu.md`.
Verification:
- `bun test 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`
- `bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane runtime-compat --lane docs src/main/runtime/tray-runtime.ts src/main/runtime/tray-main-actions.ts src/main/runtime/tray-main-deps.ts src/main.ts changes/303-tray-help-menu.md`
- `bun run changelog:lint`
Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260425-211156-9fkdDf/`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-306
title: Separate background stats daemon from regular SubMiner app
status: Done
assignee: []
created_date: '2026-04-27 00:56'
updated_date: '2026-04-27 01:00'
labels:
- stats
- runtime
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Background stats mode should run only the stats data/server pieces. It must not bring up tray UI or expose the regular mpv connection surface, and stopping should remain CLI-only.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launching stats background mode starts a separate stats daemon process rather than booting the regular SubMiner runtime.
- [x] #2 Background stats mode does not create or keep a tray icon.
- [x] #3 Background stats mode does not start mpv IPC/client surfaces that let mpv connect to the app.
- [x] #4 Background stats mode remains stoppable through the stats stop command line path.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add entry-runtime tests for public stats background/stop daemon detection.
2. Implement early public stats daemon command detection and route it before regular app boot.
3. Run targeted tests and update task status/criteria.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented early public stats daemon routing in main-entry runtime. Direct `--stats-background` and `--stats-stop` now resolve to daemon control before single-instance lock and before loading `main.js`, matching the existing internal launcher daemon flags. Installed missing Bun dependencies to run targeted tests.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added `resolveStatsDaemonCommandAction` and updated entry detection so public `--stats-background` / `--stats-stop` invocations route through the isolated stats daemon control path.
- Reused that action resolution in `stats-daemon-entry` so public stop commands map to stop instead of the default start path.
- Added regression coverage for public daemon detection/action resolution.
Verification:
- `bun test src/main-entry-runtime.test.ts launcher/commands/command-modules.test.ts src/main/runtime/stats-cli-command.test.ts src/stats-daemon-control.test.ts`
- `bun run typecheck`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,58 @@
---
id: TASK-307
title: Defer in-app stats server to running background stats daemon
status: Done
assignee: []
created_date: '2026-04-27 01:57'
updated_date: '2026-04-27 02:02'
labels:
- stats
- runtime
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When normal SubMiner app startup has stats auto-start enabled, it should detect an already-running background stats daemon and avoid starting a second in-app stats server. Stats overlay/dashboard URL resolution should point at the background daemon.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 If a live background stats daemon state exists for another process, in-app stats auto-start does not start a local stats server.
- [x] #2 Stats URL resolution returns the background daemon URL when the background daemon is live.
- [x] #3 Stale or dead background daemon state is cleared and normal in-app stats startup still works.
- [x] #4 Regression tests cover the deferral behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add unit tests for stats server routing decisions around live/stale background daemon state.
2. Implement a small routing helper used by main stats startup.
3. Wire `ensureStatsServerStarted()` through the helper.
4. Run targeted tests/typecheck/changelog lint and finalize the task.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Extracted stats server URL routing into `src/main/runtime/stats-server-routing.ts` and wired `main.ts` through it. The helper returns the background daemon URL without calling local server startup when a live external daemon exists; dead/self-owned stale state is removed before falling back to local startup. Added the new test to `test:core:src`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added a pure stats server routing helper that chooses between a live background daemon and local in-app stats server startup.
- Updated main stats URL resolution to defer to another process's background daemon and only start the in-app server when no live daemon is available.
- Added regression tests for live daemon deferral, dead daemon cleanup, self-owned stale state cleanup, and local server reuse.
- Added the routing test to the core source test lane and added a changelog fragment.
Verification:
- `bun test src/main/runtime/stats-server-routing.test.ts src/main-entry-runtime.test.ts src/main/runtime/stats-cli-command.test.ts src/stats-daemon-control.test.ts`
- `bun run test:core:src`
- `bun run typecheck`
- `bun run changelog:lint`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,28 @@
---
id: TASK-313
title: Fix mpv buffering reload overlay lifecycle
status: To Do
assignee: []
created_date: '2026-05-02 22:12'
labels:
- bug
- mpv
- overlay
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
macOS local playback can emit an mpv reload/end-file/file-loaded sequence during buffering. SubMiner should treat same-media reload churn as a continuation, not a fresh playback session, so the visible overlay remains available and startup-only tokenization/AniSkip work is not repeated unnecessarily.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Same-media mpv reload buffering does not hide the visible overlay.
- [ ] #2 Same-media mpv reload buffering does not re-arm the pause-until-ready startup gate or wait for a second tokenization-ready signal.
- [ ] #3 Same-media mpv reload buffering does not repeat AniSkip lookup work for the already-loaded media.
- [ ] #4 Normal new-file playback still clears per-media state, applies managed subtitle defaults, auto-starts/updates the overlay, and runs needed startup checks.
- [ ] #5 Regression coverage exercises the buffering reload/end-file/file-loaded sequence in the mpv plugin lifecycle.
<!-- AC:END -->
@@ -0,0 +1,35 @@
---
id: TASK-317
title: Add browser open affordance for texthooker
status: Done
assignee: []
created_date: '2026-05-03 02:02'
updated_date: '2026-05-03 02:21'
labels:
- feature
- texthooker
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a `-o` flag to the texthooker subcommand to open the texthooker page in the user's default browser, and add a tray app option that triggers the same behavior. Implement with tests and existing launcher/tray patterns.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `texthooker -o` starts/targets the texthooker page and opens it in the default browser.
- [x] #2 Tray app exposes a menu option to open the texthooker page in the default browser.
- [x] #3 Existing texthooker behavior without `-o` remains unchanged.
- [x] #4 Relevant CLI/tray behavior covered by tests.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented `subminer texthooker -o` by parsing the launcher subcommand flag, forwarding `--open-browser` to the app texthooker command, and allowing that app arg to force browser opening even when `texthooker.openBrowser` is false. Added an `Open Texthooker` tray menu item wired through the same CLI command path. Updated docs-site usage/launcher/API docs and added a changelog fragment. Verification: targeted CLI/tray tests passed; `bun run typecheck` passed; `bun run docs:test` passed; `bun run changelog:lint` passed; `bun run test:env` passed; `bun run build` passed; `bun run test:smoke:dist` passed; `bun run docs:build` passed after installing docs-site deps. `bun run test:fast` is blocked by an existing broader-suite failure in `runSubsyncManual writes deterministic _retimed filename when replace is false` (`window.electronAPI` undefined), followed by Bun nested-test cascade errors.
Follow-up fix: `subminer texthooker -o` now opens `http://127.0.0.1:5174` from the launcher after a successful texthooker app handoff, so it works even when the installed SubMiner app binary does not yet understand the app-side `--open-browser` flag. Reproduced the reported behavior; confirmed the texthooker server was running at `127.0.0.1:5174`; added a launcher regression asserting the browser URL is opened. Verification: `bun test launcher/mpv.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts src/core/services/cli-command.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts` passed; `bun run typecheck` passed; `bun run build:launcher` passed.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,55 @@
---
id: TASK-325
title: Fix keyboard-only Yomitan popup shortcut precedence
status: Done
assignee:
- codex
created_date: '2026-05-04 01:19'
updated_date: '2026-05-04 01:22'
labels:
- bug
- keyboard
- yomitan
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When keyboard-only mode is active and a Yomitan popup is visible, popup keyboard controls must win over overlay/mpv/session keybindings. Currently default overlay bindings such as bare `j` can fire instead of scrolling/navigating the Yomitan popup.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 With keyboard-only mode active and a Yomitan popup visible, pressing `j`/`k` forwards to the Yomitan popup instead of dispatching default session bindings such as primary subtitle track cycling.
- [x] #2 With keyboard-only mode inactive, existing popup-visible session binding behavior remains unchanged for bound keys.
- [x] #3 Regression coverage captures the keyboard-only popup precedence behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused regression in `src/renderer/handlers/keyboard.test.ts`: keyboard-only mode + visible Yomitan popup + bare `KeyJ` session binding should forward `KeyJ` to the popup and not dispatch the mpv/session binding.
2. Verify the new test fails before production changes.
3. Patch `src/renderer/handlers/keyboard.ts` so popup key handling ignores session-binding precedence only while keyboard-driven mode is enabled.
4. Run targeted renderer keyboard tests, then update acceptance criteria and final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implementation: `handleYomitanPopupKeybind` now only lets configured session bindings take precedence when keyboard-driven mode is not enabled. In keyboard-only mode with a visible Yomitan popup, bare popup keys such as `KeyJ` forward to the popup instead of dispatching overlay/mpv keybindings. Added a regression covering `KeyJ` bound to `cycle sid`.
Verification: targeted test failed before the production change, then passed after the fix. Full local gates run: `bun test src/renderer/handlers/keyboard.test.ts --test-name-pattern "keyboard mode: popup keybinds take precedence"`, `bun test src/renderer/handlers/keyboard.test.ts`, `bun run changelog:lint`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`. Build initially required `bun install --frozen-lockfile`, submodule init, and `stats/` locked deps install because this worktree had no dependencies/submodules checked out.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed keyboard-only Yomitan popup shortcut precedence by allowing popup key forwarding to bypass configured session bindings only while keyboard-driven mode is active. This makes popup controls such as `j`/`k` win over default overlay/mpv bindings like primary subtitle track cycling, while preserving existing non-keyboard-only popup behavior where configured bindings still fire.
Added renderer keyboard regression coverage for the reported `KeyJ`/`cycle sid` conflict and added a changelog fragment for the user-visible overlay fix.
Verification passed: targeted red/green regression, full renderer keyboard test file, changelog lint, typecheck, `test:fast`, `test:env`, build, and dist smoke tests.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,5 @@
type: added
area: dictionary
- Added CLI and in-app AniList selection for character dictionary mismatches, with series-scoped overrides that replace stale wrong-title entries in the merged dictionary.
- Added launcher support through `subminer dictionary --candidates` and `subminer dictionary --select`, plus a default `Ctrl+Alt+A` shortcut for the in-app selector.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Linux multi-line subtitle copy timing out after the prompt by letting the overlay handle the follow-up digit locally.
@@ -0,0 +1,4 @@
type: fixed
area: tokenizer
- Stopped standalone `あ` interjections from receiving subtitle annotation metadata such as N+1, JLPT, and frequency highlighting when POS tags are unavailable.
@@ -0,0 +1,4 @@
type: added
area: overlay
- Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar without changing mpv native subtitle visibility.
@@ -0,0 +1,4 @@
type: fixed
area: mpv
- Stopped mpv from owning long-running SubMiner AppImage subprocesses during playback shutdown, preventing desktop crash notifications when closing video.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed annotated subtitle token colors so `subtitleStyle` typography is preserved and higher-priority known-word/frequency colors are not overridden by JLPT colors.
@@ -0,0 +1,4 @@
type: fixed
area: tokenizer
- Stopped kana-only grammar-helper merges such as `ことに` from receiving subtitle annotation metadata like N+1, JLPT, known-word, or frequency highlighting.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Anki: Manual clipboard subtitle updates now replace both expression and sentence audio fields even when configured audio overwrite is disabled.
@@ -0,0 +1,4 @@
type: fixed
area: config
- Accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`, so setting it to `transparent` removes hover token backgrounds.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Launcher-managed playback now exits the background SubMiner app when the video closes, while explicit background launches stay persistent.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Added a subtle brightness lift for annotated subtitle token hover states so transparent hover backgrounds still show a visible hover affordance.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: tray
- Tray: Replaced the Open Overlay tray menu item with Open Help, which opens the session help modal.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats background mode now routes through the isolated stats daemon instead of starting the regular SubMiner app runtime.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- In-app stats startup now defers to an already-running background stats daemon instead of starting a second stats server.
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: release
- Replaced the changelog renderer with a `claude -p` polish pass that merges related fragments, drops PR housekeeping, and writes user-friendly release notes. CHANGELOG.md keeps internal items in a collapsed `<details>` block; the GitHub release notes drop them entirely.
- Removed the release CI auto-build for pending `changes/*.md` fragments. Tag-based release runs now fail fast with a clear error if fragments are still pending; build the changelog locally with `bun run changelog:build` (which requires the `claude` CLI on PATH) and commit before tagging.
@@ -0,0 +1,4 @@
type: fixed
area: mpv
- Kept the visible overlay alive across same-media mpv reloads during buffering, avoiding duplicate startup gates and AniSkip lookups.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Improved Jellyfin setup with recent server selection and inline authentication feedback.
- Added a tray Jellyfin Discovery toggle for runtime-only cast discovery.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: texthooker
- Texthooker: Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed keyboard-only Yomitan popup controls so popup shortcuts take precedence over overlay keybindings like `j`.
+6
View File
@@ -31,6 +31,12 @@ Rules:
- `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
+2
View File
@@ -172,6 +172,7 @@
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
@@ -482,6 +483,7 @@
"jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting.
+2
View File
@@ -213,6 +213,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
}
```
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated audio in both the expression audio field and sentence audio field.
## AI Translation
SubMiner can auto-translate the mined sentence and fill the translation field.
+1
View File
@@ -5,6 +5,7 @@
**Changed**
- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
- Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
- Overlay: Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar instead of mpv's native primary subtitle visibility.
- Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
- Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
- Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
+74 -48
View File
@@ -28,9 +28,9 @@ Character dictionary sync is disabled by default. To turn it on:
"enabled": true,
"accessToken": "your-token",
"characterDictionary": {
"enabled": true
}
}
"enabled": true,
},
},
}
```
@@ -47,33 +47,35 @@ If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
**Spacing and combination:**
- Full name with space: 須々木 心一
- Combined form: 須々木心一
- Family name alone: 須々木
- Given name alone: 心一
**Middle-dot removal** (common in katakana foreign names):
- ア・リ・ス → アリス (combined), plus individual segments
**Honorific suffixes** — each base name is expanded with 15 common suffixes:
| Honorific | Reading |
| --- | --- |
| さん | さん |
| 様 | さま |
| 先生 | せんせい |
| 先輩 | せんぱい |
| 後輩 | こうはい |
| 氏 | し |
| 君 | くん |
| くん | くん |
| ちゃん | ちゃん |
| たん | たん |
| 坊 | ぼう |
| 殿 | どの |
| 博士 | はかせ |
| 社長 | しゃちょう |
| 部長 | ぶちょう |
| Honorific | Reading |
| --------- | ---------- |
| さん | さん |
| 様 | さま |
| 先生 | せんせい |
| 先輩 | せんぱい |
| 後輩 | こうはい |
| 氏 | し |
| 君 | くん |
| くん | くん |
| ちゃん | ちゃん |
| たん | たん |
| 坊 | ぼう |
| 殿 | どの |
| 博士 | はかせ |
| 社長 | しゃちょう |
| 部長 | ぶちょう |
**Romanized names** — names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text.
@@ -92,10 +94,10 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
| Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Dictionary Entries
@@ -117,10 +119,10 @@ The three collapsible sections can be configured to start open or closed:
"collapsibleSections": {
"description": false,
"characterInformation": false,
"voicedBy": false
}
}
}
"voicedBy": false,
},
},
},
}
```
@@ -143,7 +145,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
{
"activeMediaIds": [170942, 163134, 154587],
"mergedRevision": "a1b2c3d4e5f6",
"mergedDictionaryTitle": "SubMiner Character Dictionary"
"mergedDictionaryTitle": "SubMiner Character Dictionary",
}
```
@@ -163,6 +165,29 @@ SubMiner.AppImage --dictionary
This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
## Correcting AniList Matches
SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
Use the in-app selector or CLI to pin the correct AniList media for the whole series:
```bash
# List candidate AniList matches for a file
subminer dictionary --candidates "/path/to/episode.mkv"
# Save the correct AniList media ID for that series
subminer dictionary --select 21355 "/path/to/episode.mkv"
# Equivalent direct app flags
SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv"
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
# Open the in-app selector from the running app
subminer app --open-character-dictionary
```
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
## File Structure
All character dictionary data lives under `{userData}/character-dictionaries/`:
@@ -174,6 +199,7 @@ character-dictionaries/
anilist-163134.json
merged.zip # Active merged dictionary (imported into Yomitan)
auto-sync-state.json # Tracks active media IDs and revision
anilist-overrides.json # Manual series-to-AniList overrides
img/
m170942-c12345.jpg # Character portrait
m170942-va67890.jpg # Voice actor portrait
@@ -194,16 +220,16 @@ merged.zip
## Configuration Reference
| Option | Default | Description |
| --- | --- | --- |
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
| Option | Default | Description |
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
## Reference Implementation
@@ -211,14 +237,14 @@ SubMiner's character dictionary builder is inspired by the [Japanese Character N
The reference implementation covers similar ground — name variant generation, honorific expansion, structured Yomitan content, portrait embedding — and additionally supports VNDB as a data source for visual novel characters. Key differences:
| | SubMiner | Reference Implementation |
| --- | --- | --- |
| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
| **Data sources** | AniList only | AniList + VNDB |
| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export |
| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) |
| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh |
| | SubMiner | Reference Implementation |
| ---------------------- | -------------------------------------------- | ------------------------------------- |
| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
| **Data sources** | AniList only | AniList + VNDB |
| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export |
| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) |
| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh |
If you work with visual novels or want a standalone dictionary generator independent of SubMiner, the reference implementation is worth checking out.
@@ -226,7 +252,7 @@ If you work with visual novels or want a standalone dictionary generator indepen
- **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters.
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
- **Wrong characters showing:** The merged dictionary includes your `maxLoaded` most recent titles. If you're seeing names from a previous show, they'll rotate out once you watch enough new titles to push it past the limit.
- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`.
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
+63 -56
View File
@@ -310,7 +310,7 @@ 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 (default: semi-transparent dark) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `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`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
@@ -535,6 +535,7 @@ See `config.example.jsonc` for detailed configuration options.
"mineSentence": "CommandOrControl+S",
"mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A",
"openCharacterDictionary": "CommandOrControl+Alt+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
"openSessionHelp": "CommandOrControl+Shift+H",
"openControllerSelect": "Alt+C",
@@ -559,10 +560,11 @@ See `config.example.jsonc` for detailed configuration options.
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
@@ -689,6 +691,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
| `Ctrl+Alt+A` | Open character dictionary AniList selector |
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
@@ -854,59 +857,59 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `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.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`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `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.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`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1154,6 +1157,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
@@ -1171,6 +1175,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
@@ -1203,6 +1208,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
### Discord Rich Presence
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
+11 -3
View File
@@ -6,7 +6,8 @@ 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/user/password input
- opening an in-app setup window for server selection and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements
@@ -23,6 +24,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "your-user",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
@@ -48,6 +50,8 @@ 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.
3. List libraries:
```bash
@@ -66,6 +70,8 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d
```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app:
```bash
@@ -129,12 +135,13 @@ remote playback target in Jellyfin's cast-to-device menu.
- `jellyfin.enabled=true`
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
- `jellyfin.remoteControlEnabled=true` (default)
- `jellyfin.remoteControlAutoConnect=true` (default)
- `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
### Behavior
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled.
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
- `Playstate` events map to mpv pause/resume/seek/stop controls.
@@ -147,7 +154,8 @@ remote playback target in Jellyfin's cast-to-device menu.
- Device not visible in Jellyfin cast menu:
- ensure SubMiner is running
- ensure session token is valid (`--jellyfin-login` again if needed)
- ensure `remoteControlEnabled` and `remoteControlAutoConnect` are true
- ensure `remoteControlEnabled` is true
- use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery
- Cast command received but playback does not start:
- verify mpv IPC can connect (`--start` flow)
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
+32 -29
View File
@@ -69,40 +69,43 @@ subminer stats -b # start background stats daemon
## Subcommands
| Subcommand | Purpose |
| ---------------------------- | ---------------------------------------------------------- |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
| `subminer stats` | Start stats server and open immersion dashboard in browser |
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
| `subminer mpv socket` | Print active socket path |
| `subminer mpv idle` | Launch detached idle mpv instance |
| `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target |
| `subminer texthooker` | Launch texthooker-only mode |
| `subminer app` | Pass arguments directly to SubMiner binary |
| Subcommand | Purpose |
| ------------------------------------------ | ------------------------------------------------------------------ |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
| `subminer stats` | Start stats server and open immersion dashboard in browser |
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
| `subminer mpv socket` | Print active socket path |
| `subminer mpv idle` | Launch detached idle mpv instance |
| `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target |
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
| `subminer texthooker` | Launch texthooker-only mode |
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
| `subminer app` | Pass arguments directly to SubMiner binary |
Use `subminer <subcommand> -h` for command-specific help.
## Options
| Flag | Description |
| --------------------- | --------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `--setup` | Open first-run setup popup manually |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
| Flag | Description |
| --------------------- | -------------------------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `--setup` | Open first-run setup popup manually |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
+2
View File
@@ -100,6 +100,8 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
Manual clipboard updates always replace generated audio in both the expression audio field and sentence audio field, even when `ankiConnect.behavior.overwriteAudio` is disabled. The manual flow assumes you are intentionally replacing the proxy-generated clip on the newest card.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
| Shortcut | Action | Config key |
+3
View File
@@ -41,11 +41,14 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open settings window |
| `y-r` | Restart overlay |
| `y-c` | Check status |
| `y-k` | Skip intro (AniSkip) |
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
## Menu
Press `y-y` to open an interactive menu in mpv's OSD:
+2
View File
@@ -172,6 +172,7 @@
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
@@ -482,6 +483,7 @@
"jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting.
+36 -31
View File
@@ -35,27 +35,28 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action |
| -------------------- | -------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| Shortcut | Action |
| -------------------- | --------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `V` | Toggle primary subtitle bar visibility |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
| `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv |
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
| `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv |
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
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.
@@ -63,16 +64,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ------------------------------ |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| 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+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
@@ -99,11 +101,14 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
| `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 |
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.
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
## Drag-and-Drop
@@ -115,7 +120,7 @@ When the overlay has focus, press `y` then `d` to toggle DevTools (debugging hel
## Customizing Shortcuts
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Shift+M"`. Use `null` to disable a shortcut.
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Alt+A"`. Use `null` to disable a shortcut.
```jsonc
{
+13
View File
@@ -100,18 +100,23 @@ subminer mpv socket # Print active mpv socket path
subminer mpv status # Exit 0 if socket is ready, else exit 1
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv
subminer texthooker # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser
subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow)
# Direct packaged app control
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
SubMiner.AppImage --setup # Open first-run setup popup
SubMiner.AppImage --stop # Stop overlay
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
@@ -124,9 +129,14 @@ SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-s
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-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
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector
SubMiner.AppImage --help # Show all options
```
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
### Logging and App Mode
- `--log-level` controls logger verbosity.
@@ -166,6 +176,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer config`: config helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
- Subcommand help pages are available (for example `subminer jellyfin -h`).
@@ -321,6 +332,8 @@ 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.
`Ctrl/Cmd+Shift+H` 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.
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.
+3 -1
View File
@@ -164,6 +164,8 @@ Start it with either:
```bash
subminer texthooker
# or open the page immediately
subminer texthooker -o
```
or by leaving `texthooker.launchAtStartup` enabled.
@@ -273,7 +275,7 @@ Examples:
Examples:
- open a media picker, then call `subminer /path/to/file.mkv`
- launch browser-only subtitle tooling with `subminer texthooker`
- launch browser-only subtitle tooling with `subminer texthooker -o`
- disable the helper UI for a session with `subminer --no-texthooker video.mkv`
#### Build an overlay-adjacent client
+18 -6
View File
@@ -2,16 +2,28 @@
# Releasing
## Prerequisites
- `claude` (Claude Code CLI) installed, on `PATH`, and authenticated.
`changelog:build` and `changelog:prerelease-notes` invoke
`claude -p --model sonnet` to merge and rewrite `changes/*.md` fragments into
a polished, user-facing release body. Either OAuth login (`claude /login`) or
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
you don't already have it.
## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
3. Run `bun run changelog:lint`.
4. Bump `package.json` to the release version.
5. Build release metadata before tagging:
5. Build release metadata before tagging (this calls `claude -p` locally):
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
- Release CI now also auto-runs this step when releasing directly from a tag and `changes/*.md` fragments remain.
6. Review `CHANGELOG.md` and `release/release-notes.md`.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
before tagging. Release CI no longer auto-builds the changelog; it fails
fast if `changes/*.md` fragments are still present on a tag-based run.
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
missed something — the committed Markdown is what ships.
7. Run release gate locally:
`bun run changelog:check --version <version>`
`bun run verify:config-example`
@@ -31,7 +43,7 @@
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
2. Run `bun run changelog:lint`.
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
4. Run the prerelease gate locally:
4. Run the prerelease gate locally (this calls `claude -p` locally):
`bun run changelog:prerelease-notes --version <version>`
`bun run verify:config-example`
`bun run typecheck`
@@ -51,8 +63,8 @@ Notes:
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
+37 -1
View File
@@ -190,7 +190,43 @@ test('dictionary command forwards --dictionary and target path to app binary', (
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
assert.deepEqual(forwarded, [['--start', '--dictionary', '--dictionary-target', '/tmp/anime']]);
});
test('dictionary command forwards candidate and selection modes to app binary', () => {
const candidatesContext = createContext();
candidatesContext.args.dictionary = true;
candidatesContext.args.dictionaryCandidates = true;
candidatesContext.args.dictionaryTarget = '/tmp/anime.mkv';
const selectContext = createContext();
selectContext.args.dictionary = true;
selectContext.args.dictionarySelect = true;
selectContext.args.dictionaryAnilistId = 21355;
selectContext.args.dictionaryTarget = '/tmp/anime.mkv';
const forwarded: string[][] = [];
runDictionaryCommand(candidatesContext, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
runDictionaryCommand(selectContext, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.deepEqual(forwarded, [
['--start', '--dictionary-candidates', '--dictionary-target', '/tmp/anime.mkv'],
[
'--start',
'--dictionary-select',
'--dictionary-anilist-id',
'21355',
'--dictionary-target',
'/tmp/anime.mkv',
],
]);
});
test('dictionary command returns after app handoff starts', () => {
+14 -1
View File
@@ -18,7 +18,20 @@ export function runDictionaryCommand(
return false;
}
const forwarded = ['--dictionary'];
const forwarded = [
'--start',
args.dictionaryCandidates
? '--dictionary-candidates'
: args.dictionarySelect
? '--dictionary-select'
: '--dictionary',
];
if (args.dictionarySelect) {
if (!args.dictionaryAnilistId) {
throw new Error('Dictionary selection requires an AniList media ID.');
}
forwarded.push('--dictionary-anilist-id', String(args.dictionaryAnilistId));
}
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
forwarded.push('--dictionary-target', args.dictionaryTarget);
}
@@ -28,6 +28,7 @@ function createContext(): LauncherCommandContext {
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
passwordStore: '',
@@ -44,6 +45,8 @@ function createContext(): LauncherCommandContext {
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
+37
View File
@@ -129,6 +129,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
@@ -141,6 +144,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
doctorRefreshKnownWords: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
assert.equal(parsed.jellyfin, false);
@@ -154,3 +158,36 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
assert.equal(parsed.configShow, true);
assert.equal(parsed.logLevel, 'warn');
});
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
texthookerTriggered: true,
texthookerLogLevel: null,
texthookerOpenBrowser: true,
});
assert.equal(parsed.texthookerOnly, true);
assert.equal(parsed.texthookerOpenBrowser, true);
});
+23
View File
@@ -89,6 +89,14 @@ function parseDictionaryTarget(value: string): string {
return resolved;
}
function parseDictionaryAnilistId(value: string): number {
const id = Number.parseInt(value, 10);
if (!Number.isSafeInteger(id) || id <= 0 || String(id) !== value.trim()) {
fail(`Invalid AniList media ID: ${value}`);
}
return id;
}
export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {},
@@ -138,6 +146,8 @@ export function createDefaultArgs(
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
statsBackground: false,
statsStop: false,
@@ -174,6 +184,7 @@ export function createDefaultArgs(
useTexthooker: true,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
passwordStore: '',
@@ -214,6 +225,11 @@ export function applyRootOptionsToArgs(
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
if (invocations.dictionaryTriggered) parsed.dictionary = true;
if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true;
if (invocations.dictionarySelect) parsed.dictionarySelect = true;
if (invocations.dictionaryAnilistId) {
parsed.dictionaryAnilistId = parseDictionaryAnilistId(invocations.dictionaryAnilistId);
}
if (invocations.statsTriggered) parsed.stats = true;
if (invocations.statsBackground) parsed.statsBackground = true;
if (invocations.statsStop) parsed.statsStop = true;
@@ -222,10 +238,17 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
if (invocations.dictionaryTarget) {
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
} else if (
invocations.dictionaryTriggered &&
!invocations.dictionaryCandidates &&
!invocations.dictionarySelect
) {
fail('Dictionary target path is required.');
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
if (invocations.jellyfinInvocation) {
if (invocations.jellyfinInvocation.logLevel) {
@@ -35,3 +35,10 @@ test('parseCliPrograms routes app alias arguments through passthrough mode', ()
appArgs: ['--anilist', '--log-level', 'debug'],
});
});
test('parseCliPrograms captures texthooker browser-open flag', () => {
const result = parseCliPrograms(['texthooker', '-o'], 'subminer');
assert.equal(result.invocations.texthookerTriggered, true);
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
+28 -4
View File
@@ -27,6 +27,9 @@ export interface CliInvocations {
dictionaryTriggered: boolean;
dictionaryTarget: string | null;
dictionaryLogLevel: string | null;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId: string | null;
statsTriggered: boolean;
statsBackground: boolean;
statsStop: boolean;
@@ -39,6 +42,7 @@ export interface CliInvocations {
doctorRefreshKnownWords: boolean;
texthookerTriggered: boolean;
texthookerLogLevel: string | null;
texthookerOpenBrowser: boolean;
}
function applyRootOptions(program: Command): void {
@@ -136,6 +140,9 @@ export function parseCliPrograms(
let dictionaryTriggered = false;
let dictionaryTarget: string | null = null;
let dictionaryLogLevel: string | null = null;
let dictionaryCandidates = false;
let dictionarySelect = false;
let dictionaryAnilistId: string | null = null;
let statsTriggered = false;
let statsBackground = false;
let statsStop = false;
@@ -146,6 +153,7 @@ export function parseCliPrograms(
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let texthookerLogLevel: string | null = null;
let texthookerOpenBrowser = false;
let doctorTriggered = false;
let texthookerTriggered = false;
@@ -207,13 +215,23 @@ export function parseCliPrograms(
commandProgram
.command('dictionary')
.alias('dict')
.description('Generate character dictionary ZIP from a file or directory target')
.argument('<target>', 'Video file path or anime directory path')
.description('Generate or correct character dictionary AniList matches')
.argument('[target]', 'Video file path or anime directory path')
.option('--candidates', 'List AniList candidates for a character dictionary target')
.option('--select <id>', 'Pin an AniList media ID for the target series')
.option('--log-level <level>', 'Log level')
.action((target: string, options: Record<string, unknown>) => {
.action((target: string | undefined, options: Record<string, unknown>) => {
const selectValue = typeof options.select === 'string' ? options.select.trim() : '';
const hasSelect = selectValue.length > 0;
if (options.candidates === true && hasSelect) {
throw new Error('Dictionary --candidates and --select cannot be combined.');
}
dictionaryTriggered = true;
dictionaryTarget = target;
dictionaryTarget = target ?? null;
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
dictionaryCandidates = options.candidates === true;
dictionarySelect = hasSelect;
dictionaryAnilistId = hasSelect ? selectValue : null;
});
commandProgram
@@ -297,10 +315,12 @@ export function parseCliPrograms(
commandProgram
.command('texthooker')
.description('Launch texthooker-only mode')
.option('-o, --open-browser', 'Open texthooker in the default browser')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
texthookerTriggered = true;
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
texthookerOpenBrowser = options.openBrowser === true;
});
commandProgram
@@ -338,6 +358,9 @@ export function parseCliPrograms(
dictionaryTriggered,
dictionaryTarget,
dictionaryLogLevel,
dictionaryCandidates,
dictionarySelect,
dictionaryAnilistId,
statsTriggered,
statsBackground,
statsStop,
@@ -350,6 +373,7 @@ export function parseCliPrograms(
doctorRefreshKnownWords,
texthookerTriggered,
texthookerLogLevel,
texthookerOpenBrowser,
},
};
}
+34 -1
View File
@@ -464,7 +464,40 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
assert.equal(result.status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--dictionary\n--dictionary-target\n${targetPath}\n`,
`--start\n--dictionary\n--dictionary-target\n${targetPath}\n`,
);
});
});
test('dictionary command forwards manual AniList selection modes to app command path', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const targetPath = path.join(root, 'anime.mkv');
fs.writeFileSync(targetPath, '');
assert.equal(runLauncher(['dictionary', '--candidates', targetPath], env).status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--start\n--dictionary-candidates\n--dictionary-target\n${targetPath}\n`,
);
assert.equal(runLauncher(['dictionary', '--select', '21355', targetPath], env).status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--start\n--dictionary-select\n--dictionary-anilist-id\n21355\n--dictionary-target\n${targetPath}\n`,
);
});
});
+26
View File
@@ -270,6 +270,29 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
assert.equal(error.code, 1);
});
test('launchTexthookerOnly forwards browser-open request to app command', () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const argsPath = path.join(dir, 'args.txt');
const openedUrls: string[] = [];
fs.writeFileSync(appPath, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsPath}"\nexit 0\n`);
fs.chmodSync(appPath, 0o755);
const error = withProcessExitIntercept(() => {
launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), {
openBrowser: (url) => openedUrls.push(url),
});
});
assert.equal(error.code, 0);
assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [
'--texthooker',
'--open-browser',
]);
assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']);
fs.rmSync(dir, { recursive: true, force: true });
});
test('launchAppCommandDetached handles child process spawn errors', async () => {
let uncaughtError: Error | null = null;
const onUncaughtException = (error: Error) => {
@@ -399,6 +422,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'error',
passwordStore: '',
@@ -415,6 +439,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
+30 -1
View File
@@ -831,8 +831,30 @@ export async function startOverlay(
}
}
export function launchTexthookerOnly(appPath: string, args: Args): never {
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
? { command: 'open', args: [url] }
: process.platform === 'win32'
? { command: 'cmd', args: ['/c', 'start', '', url] }
: { command: 'xdg-open', args: [url] };
const result = spawnSync(target.command, target.args, {
stdio: 'ignore',
env: process.env,
windowsHide: true,
});
if (result.error) {
log('warn', logLevel, `Failed to open browser for ${url}: ${result.error.message}`);
}
}
export function launchTexthookerOnly(
appPath: string,
args: Args,
deps: { openBrowser?: (url: string) => void } = {},
): never {
const overlayArgs = ['--texthooker'];
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...');
@@ -840,6 +862,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
if (result.error) {
fail(`Failed to launch texthooker mode: ${result.error.message}`);
}
if (args.texthookerOpenBrowser && (result.status ?? 0) === 0) {
const url = 'http://127.0.0.1:5174';
const openBrowser =
deps.openBrowser ??
((browserUrl: string) => openUrlInDefaultBrowser(browserUrl, args.logLevel));
openBrowser(url);
}
process.exit(result.status ?? 0);
}
+19
View File
@@ -99,6 +99,25 @@ test('parseArgs maps dictionary command and log-level override', () => {
assert.equal(parsed.logLevel, 'debug');
});
test('parseArgs maps dictionary candidate lookup and manual selection', () => {
const candidateParsed = parseArgs(['dictionary', '--candidates', '.'], 'subminer', {});
assert.equal(candidateParsed.dictionaryCandidates, true);
assert.equal(candidateParsed.dictionaryTarget, process.cwd());
const selectParsed = parseArgs(['dictionary', '--select', '21355', '.'], 'subminer', {});
assert.equal(selectParsed.dictionarySelect, true);
assert.equal(selectParsed.dictionaryAnilistId, 21355);
assert.equal(selectParsed.dictionaryTarget, process.cwd());
});
test('parseArgs rejects conflicting dictionary candidate and selection modes', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['dictionary', '--candidates', '--select', '21355', '.'], 'subminer', {});
});
assert.equal(exit.code, 1);
});
test('parseArgs maps stats command and log-level override', () => {
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});
+4
View File
@@ -105,6 +105,7 @@ export interface Args {
useTexthooker: boolean;
autoStartOverlay: boolean;
texthookerOnly: boolean;
texthookerOpenBrowser: boolean;
useRofi: boolean;
logLevel: LogLevel;
passwordStore: string;
@@ -121,6 +122,9 @@ export interface Args {
jellyfinPlay: boolean;
jellyfinDiscovery: boolean;
dictionary: boolean;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId?: number;
stats: boolean;
statsBackground?: boolean;
statsStop?: boolean;
+2 -2
View File
@@ -48,7 +48,7 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.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/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test 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/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/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/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/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/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/setup-gate.test.ts stats/src/lib/api-client.test.ts",
"test:core:src": "bun test 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/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/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/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/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/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/setup-gate.test.ts stats/src/lib/api-client.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: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",
@@ -70,7 +70,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",
+52 -6
View File
@@ -11,6 +11,29 @@ function M.create(ctx)
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local function resolve_media_identity()
local path = mp.get_property("path")
if type(path) == "string" and path ~= "" then
return path
end
local filename = mp.get_property("filename")
if type(filename) == "string" and filename ~= "" then
return filename
end
local media_title = mp.get_property("media-title")
if type(media_title) == "string" and media_title ~= "" then
return media_title
end
return nil
end
local function is_reload_end_file(reason)
return reason == "reload" or reason == "redirect"
end
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
local delay = tonumber(delay_seconds) or 0
mp.add_timeout(delay, function()
@@ -41,6 +64,25 @@ function M.create(ctx)
end
local function on_file_loaded()
local media_identity = resolve_media_identity()
local same_media_reload = (
media_identity ~= nil
and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity
)
state.pending_reload_media_identity = nil
state.current_media_identity = media_identity
if same_media_reload then
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then
process.run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
})
end
return
end
aniskip.clear_aniskip_state()
process.disarm_auto_play_ready_gate()
local has_matching_socket = rearm_managed_subtitle_defaults()
@@ -73,10 +115,8 @@ function M.create(ctx)
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
if state.overlay_running then
subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay")
process.hide_visible_overlay()
end
state.current_media_identity = nil
state.pending_reload_media_identity = nil
end
local function register_lifecycle_hooks()
@@ -85,10 +125,16 @@ function M.create(ctx)
mp.register_event("file-loaded", function()
hover.clear_hover_overlay()
end)
mp.register_event("end-file", function()
mp.register_event("end-file", function(event)
process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay()
if state.overlay_running then
local reason = type(event) == "table" and event.reason or nil
if is_reload_end_file(reason) then
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
return
end
state.pending_reload_media_identity = nil
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay()
end
end)
+19
View File
@@ -186,6 +186,9 @@ function M.create(ctx)
end
if action == "start" then
table.insert(args, "--background")
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend)
if backend and backend ~= "" then
table.insert(args, "--backend")
@@ -462,6 +465,21 @@ function M.create(ctx)
end)
end
local function toggle_primary_subtitle_bar()
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
run_control_command_async("toggle-primary-subtitle-bar", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Primary subtitle bar toggle command failed")
show_osd("Primary subtitle toggle failed")
end
end)
end
local function open_options()
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
@@ -552,6 +570,7 @@ function M.create(ctx)
stop_overlay = stop_overlay,
hide_visible_overlay = hide_visible_overlay,
toggle_overlay = toggle_overlay,
toggle_primary_subtitle_bar = toggle_primary_subtitle_bar,
open_options = open_options,
restart_overlay = restart_overlay,
check_status = check_status,
+2
View File
@@ -33,6 +33,8 @@ function M.new()
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false,
current_media_identity = nil,
pending_reload_media_identity = nil,
session_binding_generation = 0,
session_binding_names = {},
session_numeric_binding_names = {},
+3
View File
@@ -80,6 +80,9 @@ function M.create(ctx)
mp.add_key_binding("y-t", "subminer-toggle", function()
process.toggle_overlay()
end)
mp.add_forced_key_binding("v", "subminer-toggle-primary-subtitle-bar", function()
process.toggle_primary_subtitle_bar()
end)
mp.add_key_binding("y-y", "subminer-menu", show_menu)
mp.add_key_binding("y-o", "subminer-options", function()
process.open_options()
+362 -17
View File
@@ -13,6 +13,75 @@ function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
}
type RunClaudeArgs = { input: string; args: string[] };
function recordingRunClaude(responder: (input: string) => string): {
runClaude: (input: string, args: string[]) => string;
calls: RunClaudeArgs[];
} {
const calls: RunClaudeArgs[] = [];
return {
calls,
runClaude(input, args) {
calls.push({ input, args });
return responder(input);
},
};
}
function modeFromPrompt(input: string): 'changelog' | 'release-notes' | null {
// Anchor to start-of-line so we don't accidentally match the instructions text,
// which mentions "MODE: changelog" and "MODE: release-notes" mid-sentence.
const match = /^MODE: (changelog|release-notes)$/m.exec(input);
return (match?.[1] as 'changelog' | 'release-notes') ?? null;
}
function fragmentTypesInPrompt(input: string): string[] {
return input
.split(/\r?\n/)
.filter((line) => line.startsWith('type: '))
.map((line) => line.slice('type: '.length).trim());
}
function defaultPolishedBody(input: string): string {
const mode = modeFromPrompt(input);
const types = fragmentTypesInPrompt(input);
const sections: string[] = [];
const has = (t: string) => types.includes(t);
const hasBreaking = /^breaking: true$/m.test(input);
if (hasBreaking) {
sections.push('### Breaking Changes\n- Polished: breaking change.');
}
if (has('added')) {
sections.push('### Added\n- Polished: added entry.');
}
if (has('changed')) {
sections.push('### Changed\n- Polished: changed entry.');
}
if (has('fixed')) {
sections.push('### Fixed\n- Polished: fixed entry.');
}
if (has('docs')) {
sections.push('### Docs\n- Polished: docs entry.');
}
if (mode === 'changelog' && has('internal')) {
sections.push(
'<details>\n<summary>Internal changes</summary>\n\n### Internal\n- Polished: internal entry.\n\n</details>',
);
}
if (sections.length === 0) {
sections.push('### Changed\n- Polished: empty fallback.');
}
return sections.join('\n\n');
}
function defaultStubClaude() {
return recordingRunClaude(defaultPolishedBody);
}
test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => {
const { resolveChangelogOutputPaths } = await loadModule();
const workspace = createWorkspace('with-docs-repo');
@@ -62,10 +131,12 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
);
try {
const stub = defaultStubClaude();
const result = writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-07',
deps: { runClaude: stub.runClaude },
});
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
@@ -77,18 +148,28 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(
changelog,
/^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n### Added\n- Overlay: Added release fragments\.\n\n### Fixed\n- Release: Fixed release notes generation\.\n\n## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/m,
assert.equal(
stub.calls.length,
2,
'expected one Claude call per output (changelog + release notes)',
);
assert.deepEqual(
stub.calls.map((call) => modeFromPrompt(call.input)),
['changelog', 'release-notes'],
);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/);
assert.match(changelog, /### Added\n- Polished: added entry\./);
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./);
assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./);
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
@@ -159,10 +240,12 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
);
try {
const stub = defaultStubClaude();
const result = writeStableReleaseArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-07',
deps: { runClaude: stub.runClaude },
});
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
@@ -260,10 +343,12 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: stub.runClaude },
});
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
@@ -276,8 +361,14 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
assert.match(changelog, /### Breaking Changes\n- Config: Renamed `foo` to `bar`\./);
assert.match(changelog, /### Changed\n- Config: Renamed `foo` to `bar`\./);
const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation');
assert.match(
changelogCall.input,
/breaking: true/,
'breaking metadata should reach the prompt verbatim',
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
@@ -384,9 +475,11 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
deps: { runClaude: stub.runClaude },
});
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
@@ -403,13 +496,13 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(
prereleaseNotes,
/## Highlights\n### Added\n- Overlay: Added prerelease coverage\./,
);
assert.match(prereleaseNotes, /### Fixed\n- Launcher: Fixed prerelease packaging checks\./);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
@@ -434,16 +527,15 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-rc.1',
deps: { runClaude: stub.runClaude },
});
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
);
assert.match(prereleaseNotes, /## Highlights\n### Changed\n- Polished: changed entry\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
@@ -536,3 +628,256 @@ test('writePrereleaseNotesForVersion rejects empty prerelease note generation wh
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts surfaces a clear error when claude is missing from PATH', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-missing');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
// The production defaultRunClaude wrapper translates ENOENT into this friendly
// message; we simulate that contract here so the test exercises the propagation
// path through polishFragmentsWithClaude rather than re-implementing the
// execFileSync mock.
const enoent = (): string => {
throw new Error(
"claude CLI not found on PATH. Install Claude Code (https://claude.com/claude-code) and ensure 'claude' is on your PATH before running changelog:build.",
);
};
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: enoent },
}),
/claude CLI not found on PATH/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects empty claude output', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-empty');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: () => ' \n ' },
}),
/claude returned empty output/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects claude output missing required section headers', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-no-headers');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: () => 'Sure, here is your changelog: it is great.' },
}),
/missing the expected section heading/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects changelog-mode output that omits the Internal <details> wrapper when internal fragments are present', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-no-details');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: internal', 'area: release', '', '- An internal note.'].join('\n'),
'utf8',
);
const noDetailsResponder = (input: string): string => {
if (modeFromPrompt(input) === 'changelog') {
return '### Added\n- Polished: added.\n\n### Internal\n- Polished: internal (no details wrapper).';
}
return defaultPolishedBody(input);
};
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: noDetailsResponder },
}),
/<details><summary>Internal changes<\/summary> wrapper/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts filters internal fragments from the release-notes Claude prompt', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('release-notes-internal-filter');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: internal', 'area: release', '', '- An internal CI tweak.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: stub.runClaude },
});
const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
const releaseNotesCall = stub.calls.find(
(call) => modeFromPrompt(call.input) === 'release-notes',
);
assert.ok(changelogCall, 'expected a changelog-mode invocation');
assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation');
assert.deepEqual(
fragmentTypesInPrompt(changelogCall.input).sort(),
['added', 'internal'],
'changelog mode keeps internal fragments',
);
assert.deepEqual(
fragmentTypesInPrompt(releaseNotesCall.input),
['added'],
'release-notes mode drops internal fragments',
);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(changelog, /<details>[\s\S]*<summary>Internal changes<\/summary>/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
'## v0.4.1 (2026-03-07)',
'### Added',
'- Polished: previously committed.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'### Internal',
'- Polished: internal note.',
'',
'</details>',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Stale fragment.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-08',
deps: { runClaude: stub.runClaude },
});
assert.equal(
stub.calls.length,
0,
'no Claude calls should fire when the section already exists',
);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
+185 -52
View File
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
type RunClaude = (input: string, args: string[]) => string;
type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -10,8 +12,11 @@ type ChangelogFsDeps = {
rmSync?: (candidate: string) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void;
runClaude?: RunClaude;
};
type PolishMode = 'changelog' | 'release-notes';
type ChangelogOptions = {
cwd?: string;
date?: string;
@@ -41,13 +46,6 @@ const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
added: 'Added',
changed: 'Changed',
fixed: 'Fixed',
docs: 'Docs',
internal: 'Internal',
};
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
function normalizeVersion(version: string): string {
@@ -217,54 +215,179 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
});
}
function formatAreaLabel(area: string): string {
return area
.split(/[-_\s]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
}
// We deliberately don't pass --bare here. --bare skips OAuth/keychain reads and
// requires ANTHROPIC_API_KEY, which most Claude Code users don't have set up.
// The polish prompt is self-contained and doesn't need tools, so loading the
// user's hooks/MCP/CLAUDE.md is harmless overhead.
const CLAUDE_CLI_ARGS = [
'-p',
'--model',
'sonnet',
'--permission-mode',
'bypassPermissions',
'--output-format',
'text',
];
function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string {
return `- ${formatAreaLabel(fragment.area)}: ${bullet.replace(/^- /, '')}`;
}
const SECTION_HEADER_PATTERN = /^### (Breaking Changes|Added|Changed|Fixed|Docs|Internal)$/m;
function renderGroupedChanges(fragments: ChangeFragment[]): string {
const sections: string[] = [];
const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release changelog for end users of SubMiner, an Electron app for Japanese sentence mining.
const breakingFragments = fragments.filter((fragment) => fragment.breaking);
if (breakingFragments.length > 0) {
const bullets = breakingFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### Breaking Changes\n${bullets}`);
}
You will receive a list of FRAGMENT entries below. Each fragment has metadata (type, area, breaking) and one or more bullet points written by the engineer who shipped that change. Your job is to merge, dedupe, and rewrite these fragments into a polished, user-facing release body.
for (const type of CHANGE_TYPES) {
const typeFragments = fragments.filter((fragment) => fragment.type === type);
if (typeFragments.length === 0) {
continue;
## Output Rules
1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
2. Use these section headings, in this order, omitting any that have no bullets:
### Breaking Changes
### Added
### Changed
### Fixed
### Docs
3. In MODE: changelog only, append a final section after Docs:
<details>
<summary>Internal changes</summary>
### Internal
-
</details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Each bullet should:
- Lead with a short feature/area name in title case followed by a colon, e.g. "Playlist browser:", "Windows overlay:", "Stats dashboard:". Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of every breaking change in ### Breaking Changes. Do not soften or omit them.
5. Do not invent features. Every bullet must be grounded in the input fragments.
6. Do not include the version heading (## v...) that wrapper is added by the caller.
The input begins below.
`;
function defaultRunClaude(input: string, args: string[]): string {
try {
return execFileSync('claude', args, {
input,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'inherit'],
});
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new Error(
"claude CLI not found on PATH. Install Claude Code (https://claude.com/claude-code) and ensure 'claude' is on your PATH before running changelog:build.",
);
}
const bullets = typeFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
throw new Error(`claude CLI invocation failed: ${err.message}`);
}
return sections.join('\n\n');
}
function buildReleaseSection(version: string, date: string, fragments: ChangeFragment[]): string {
function serializeFragmentsForPrompt(
fragments: ChangeFragment[],
mode: PolishMode,
version: string,
date?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
header.push(`DATE: ${date}`);
}
const fragmentBlocks = fragments.map((fragment) => {
const relativePath = fragment.path.replace(/^.*?(changes\/.*)$/u, '$1');
return [
`FRAGMENT ${relativePath}`,
`type: ${fragment.type}`,
`area: ${fragment.area}`,
`breaking: ${fragment.breaking}`,
...fragment.bullets,
].join('\n');
});
return [...header, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
output: string,
mode: PolishMode,
hasInternalFragments: boolean,
): string {
const trimmed = output.trim();
if (!trimmed) {
throw new Error('claude returned empty output for changelog polish.');
}
if (!SECTION_HEADER_PATTERN.test(trimmed)) {
throw new Error(
`claude output is missing the expected section heading (### Added/Changed/Fixed/Docs/Breaking Changes). Got:\n${trimmed.slice(0, 400)}`,
);
}
if (mode === 'changelog' && hasInternalFragments) {
if (!/<details>[\s\S]*<summary>[^<]*Internal[^<]*<\/summary>/m.test(trimmed)) {
throw new Error(
'claude output is missing the expected <details><summary>Internal changes</summary> wrapper for the Internal section.',
);
}
}
return trimmed;
}
function polishFragmentsWithClaude(
fragments: ChangeFragment[],
options: {
mode: PolishMode;
version: string;
date?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
mode === 'release-notes'
? fragments.filter((fragment) => fragment.type !== 'internal')
: fragments;
const hasInternalFragments =
mode === 'changelog' && fragments.some((fragment) => fragment.type === 'internal');
if (filtered.length === 0) {
throw new Error(
mode === 'release-notes'
? 'No user-facing changelog fragments found in changes/ (only internal fragments are present, which are dropped from release notes).'
: 'No changelog fragments found in changes/.',
);
}
const prompt =
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
function stripDetailsBlocks(body: string): string {
return body.replace(/<details>[\s\S]*?<\/details>\s*/gm, '').trim();
}
function buildReleaseSection(
version: string,
date: string,
fragments: ChangeFragment[],
deps?: ChangelogFsDeps,
): string {
if (fragments.length === 0) {
throw new Error('No changelog fragments found in changes/.');
}
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join('\n');
const polished = polishFragmentsWithClaude(fragments, {
mode: 'changelog',
version,
date,
deps,
});
return [`## v${version} (${date})`, '', polished, ''].join('\n');
}
function ensureChangelogHeader(existingChangelog: string): string {
@@ -392,7 +515,11 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Removed ${fragment.path}`);
}
const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps);
const releaseNotesPath = writeReleaseNotesFile(
cwd,
stripDetailsBlocks(existingReleaseSection),
options?.deps,
);
log(`Generated ${releaseNotesPath}`);
return {
@@ -402,7 +529,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
};
}
const releaseSection = buildReleaseSection(version, date, fragments);
const releaseSection = buildReleaseSection(version, date, fragments, options?.deps);
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
for (const outputPath of outputPaths) {
@@ -411,11 +538,13 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Updated ${outputPath}`);
}
const releaseNotesPath = writeReleaseNotesFile(
cwd,
extractReleaseSectionBody(nextChangelog, version) ?? releaseSection,
options?.deps,
);
const releaseNotesBody = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
date,
deps: options?.deps,
});
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) {
@@ -645,7 +774,7 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
throw new Error(`Missing CHANGELOG section for v${version}.`);
}
return writeReleaseNotesFile(cwd, changes, options?.deps);
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
}
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
@@ -664,7 +793,11 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.');
}
const changes = renderGroupedChanges(fragments);
const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
deps: options?.deps,
});
return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
+110 -2
View File
@@ -151,6 +151,14 @@ local function run_plugin_scenario(config)
fn = fn,
}
end
function mp.add_forced_key_binding(keys, name, fn)
recorded.key_bindings[#recorded.key_bindings + 1] = {
keys = keys,
name = name,
fn = fn,
forced = true,
}
end
function mp.register_event(name, fn)
if recorded.events[name] == nil then
recorded.events[name] = {}
@@ -453,6 +461,20 @@ local function has_async_curl_for(async_calls, needle)
return false
end
local function count_async_curl_for(async_calls, needle)
local count = 0
for _, call in ipairs(async_calls) do
local args = call.args or {}
if args[1] == "curl" then
local url = args[#args] or ""
if type(url) == "string" and url:find(needle, 1, true) then
count = count + 1
end
end
end
return count
end
local function has_property_set(property_sets, name, value)
for _, call in ipairs(property_sets) do
if call.name == name and call.value == value then
@@ -491,10 +513,10 @@ local function count_property_set(property_sets, name, value)
return count
end
local function fire_event(recorded, name)
local function fire_event(recorded, name, ...)
local listeners = recorded.events[name] or {}
for _, listener in ipairs(listeners) do
listener()
listener(...)
end
end
@@ -537,6 +559,78 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for primary subtitle bar binding scenario: " .. tostring(err))
local binding = nil
for _, candidate in ipairs(recorded.key_bindings) do
if candidate.name == "subminer-toggle-primary-subtitle-bar" then
binding = candidate
break
end
end
assert_true(binding ~= nil, "primary subtitle bar v binding should be registered")
assert_true(binding.keys == "v", "primary subtitle bar binding should use bare v")
assert_true(binding.forced == true, "primary subtitle bar binding should override mpv's built-in v binding")
binding.fn()
assert_true(
count_control_calls(recorded.async_calls, "--toggle-primary-subtitle-bar") == 1,
"primary subtitle bar binding should issue primary subtitle toggle command"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"primary subtitle bar binding should not toggle the whole visible overlay"
)
end
do
local media_path = "/media/Sample Show S01E01.mkv"
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = media_path,
media_title = "Sample Show S01E01",
mal_lookup_stdout = "__MAL_FOUND__",
aniskip_stdout = "__ANISKIP_FOUND__",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for same-media reload scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "reload" })
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"same-media reload should not hide the visible overlay"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 1,
"same-media reload should not re-arm pause-until-ready"
)
assert_true(
count_async_curl_for(recorded.async_calls, "api.aniskip.com") == 1,
"same-media reload should not repeat AniSkip lookup"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -727,6 +821,11 @@ 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(
call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as launcher-managed playback"
)
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
assert_true(
@@ -1013,11 +1112,20 @@ do
})
assert_true(recorded ~= nil, "plugin failed to load for shutdown-preserve-background scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "end-file", { reason = "quit" })
assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
"mpv quit end-file should not spawn hide-visible-overlay helper commands"
)
fire_event(recorded, "shutdown")
assert_true(
find_control_call(recorded.async_calls, "--stop") == nil,
"mpv shutdown should not stop the background SubMiner process"
)
assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
"mpv shutdown should not spawn hide-visible-overlay helper commands"
)
end
do
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { AnkiConnectConfig } from '../types/anki';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
service: CardCreationService;
updatedFields: Record<string, string>[];
mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>;
storedMedia: string[];
} {
const updatedFields: Record<string, string>[] = [];
const mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }> = [];
const storedMedia: string[] = [];
const deps: CardCreationDeps = {
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () =>
({
findTiming: (text: string) => (text === '字幕' ? { startTime: 12, endTime: 14 } : null),
}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (preferredName && preferredName in noteInfo.fields) return preferredName;
}
return null;
},
resolveNoteFieldName: (noteInfo, preferredName) =>
preferredName && preferredName in noteInfo.fields ? preferredName : null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: (fields) =>
Object.fromEntries(
Object.entries(fields).map(([name, field]) => [name.toLowerCase(), field.value]),
),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (existing, newValue, overwrite) => {
mergeCalls.push({ existing, newValue, overwrite });
return overwrite || !existing.trim() ? newValue : existing;
},
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
...overrides,
};
return {
service: new CardCreationService(deps),
updatedFields,
mergeCalls,
storedMedia,
};
}
test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
assert.deepEqual(
mergeCalls.map((call) => call.overwrite),
[true, true],
);
});
+19 -6
View File
@@ -219,6 +219,10 @@ export class CardCreationService {
this.deps.getConfig(),
);
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const expressionAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentence = blocks.join(' ');
@@ -248,13 +252,22 @@ export class CardCreationService {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
this.deps.getConfig().behavior?.overwriteAudio !== false,
if (sentenceAudioField || expressionAudioField) {
const audioValue = `[sound:${audioFilename}]`;
const audioFields = new Set(
[sentenceAudioField, expressionAudioField].filter(
(fieldName): fieldName is string => Boolean(fieldName),
),
);
for (const audioField of audioFields) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
// Manual clipboard updates intentionally replace old captured audio.
updatedFields[audioField] = this.deps.mergeFieldValue(
existingAudio,
audioValue,
true,
);
}
}
miscInfoFilename = audioFilename;
updatePerformed = true;
+27
View File
@@ -79,6 +79,7 @@ test('parseArgs captures session action forwarding flags', () => {
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
'--toggle-primary-subtitle-bar',
'--replay-current-subtitle',
'--play-next-subtitle',
'--shift-sub-delay-prev-line',
@@ -94,6 +95,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.togglePrimarySubtitleBar, true);
assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
@@ -122,9 +124,12 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica
test('standalone texthooker classification excludes integrated start flow', () => {
const standalone = parseArgs(['--texthooker']);
const standaloneOpenBrowser = parseArgs(['--texthooker', '--open-browser']);
const integrated = parseArgs(['--start', '--texthooker']);
assert.equal(isStandaloneTexthookerCommand(standalone), true);
assert.equal(standaloneOpenBrowser.texthookerOpenBrowser, true);
assert.equal(isStandaloneTexthookerCommand(standaloneOpenBrowser), true);
assert.equal(isStandaloneTexthookerCommand(integrated), false);
});
@@ -212,6 +217,22 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false);
const dictionaryCandidates = parseArgs([
'--dictionary-candidates',
'--dictionary-target',
'/tmp/a.mkv',
]);
assert.equal(dictionaryCandidates.dictionaryCandidates, true);
assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv');
assert.equal(hasExplicitCommand(dictionaryCandidates), true);
assert.equal(shouldStartApp(dictionaryCandidates), true);
const dictionarySelect = parseArgs(['--dictionary-select', '--dictionary-anilist-id', '21355']);
assert.equal(dictionarySelect.dictionarySelect, true);
assert.equal(dictionarySelect.dictionaryAnilistId, 21355);
assert.equal(hasExplicitCommand(dictionarySelect), true);
assert.equal(shouldStartApp(dictionarySelect), true);
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
@@ -320,6 +341,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(background), true);
assert.equal(shouldStartApp(background), true);
const managedPlayback = parseArgs(['--background', '--managed-playback']);
assert.equal(managedPlayback.background, true);
assert.equal(managedPlayback.managedPlayback, true);
assert.equal(hasExplicitCommand(managedPlayback), true);
assert.equal(shouldStartApp(managedPlayback), true);
const setup = parseArgs(['--setup']);
assert.equal((setup as typeof setup & { setup?: boolean }).setup, true);
assert.equal(hasExplicitCommand(setup), true);
+44 -1
View File
@@ -1,5 +1,6 @@
export interface CliArgs {
background: boolean;
managedPlayback: boolean;
start: boolean;
launchMpv: boolean;
launchMpvTargets: string[];
@@ -8,6 +9,7 @@ export interface CliArgs {
stop: boolean;
toggle: boolean;
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
settings: boolean;
setup: boolean;
show: boolean;
@@ -28,6 +30,7 @@ export interface CliArgs {
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
openCharacterDictionary: boolean;
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean;
@@ -46,6 +49,9 @@ export interface CliArgs {
anilistSetup: boolean;
anilistRetryQueue: boolean;
dictionary: boolean;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId?: number;
dictionaryTarget?: string;
stats: boolean;
statsBackground?: boolean;
@@ -65,6 +71,7 @@ export interface CliArgs {
jellyfinRemoteAnnounce: boolean;
jellyfinPreviewAuth: boolean;
texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean;
autoStartOverlay: boolean;
generateConfig: boolean;
@@ -94,6 +101,7 @@ export type CliCommandSource = 'initial' | 'second-instance';
export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
@@ -102,6 +110,7 @@ export function parseArgs(argv: string[]): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
setup: false,
show: false,
@@ -122,6 +131,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
@@ -136,6 +146,8 @@ export function parseArgs(argv: string[]): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
statsBackground: false,
statsStop: false,
@@ -153,6 +165,7 @@ export function parseArgs(argv: string[]): CliArgs {
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
texthookerOpenBrowser: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
@@ -192,6 +205,7 @@ export function parseArgs(argv: string[]): CliArgs {
if (!arg || !arg.startsWith('--')) continue;
if (arg === '--background') args.background = true;
else if (arg === '--managed-playback') args.managedPlayback = true;
else if (arg === '--start') args.start = true;
else if (arg.startsWith('--youtube-play=')) {
const value = arg.split('=', 2)[1];
@@ -212,6 +226,7 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
@@ -232,6 +247,7 @@ export function parseArgs(argv: string[]): CliArgs {
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;
else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true;
else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
@@ -270,7 +286,15 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--anilist-setup') args.anilistSetup = true;
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
else if (arg === '--dictionary') args.dictionary = true;
else if (arg.startsWith('--dictionary-target=')) {
else if (arg === '--dictionary-candidates') args.dictionaryCandidates = true;
else if (arg === '--dictionary-select') args.dictionarySelect = true;
else if (arg.startsWith('--dictionary-anilist-id=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value;
} else if (arg === '--dictionary-anilist-id') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value;
} else if (arg.startsWith('--dictionary-target=')) {
const value = arg.split('=', 2)[1];
if (value) args.dictionaryTarget = value;
} else if (arg === '--dictionary-target') {
@@ -305,6 +329,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
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 === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--generate-config') args.generateConfig = true;
else if (arg === '--backup-overwrite') args.backupOverwrite = true;
@@ -440,6 +465,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings ||
args.setup ||
args.show ||
@@ -460,6 +486,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -477,6 +504,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.anilistSetup ||
args.anilistRetryQueue ||
args.dictionary ||
args.dictionaryCandidates ||
args.dictionarySelect ||
args.stats ||
args.jellyfin ||
args.jellyfinLogin ||
@@ -507,6 +536,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.setup &&
!args.show &&
@@ -527,6 +557,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
@@ -544,6 +575,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.dictionaryCandidates &&
!args.dictionarySelect &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
@@ -569,6 +602,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.launchMpv ||
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings ||
args.setup ||
args.copySubtitle ||
@@ -585,6 +619,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -598,6 +633,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary ||
args.dictionaryCandidates ||
args.dictionarySelect ||
args.stats ||
args.jellyfin ||
args.jellyfinPlay ||
@@ -619,6 +656,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.show &&
!args.hide &&
!args.setup &&
@@ -638,6 +676,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
@@ -655,6 +694,8 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.dictionaryCandidates &&
!args.dictionarySelect &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
@@ -679,6 +720,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return (
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -696,6 +738,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.markAudioCard ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
+1
View File
@@ -19,6 +19,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /default: 7777/);
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--anilist-status/);
+6
View File
@@ -16,9 +16,11 @@ ${B}Session${R}
--stop Stop the running instance
--stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R}
--open-browser Open texthooker in your default browser
${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
@@ -38,6 +40,7 @@ ${B}Mining${R}
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
--open-character-dictionary Open character dictionary anime selection modal
--open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal
@@ -47,6 +50,9 @@ ${B}AniList${R}
--anilist-logout Clear stored AniList token
--anilist-retry-queue Retry next queued update
--dictionary Generate character dictionary ZIP for current anime
--dictionary-candidates Show character dictionary AniList candidates
--dictionary-select Save manual character dictionary AniList selection
--dictionary-anilist-id ${D}ID${R} AniList media ID for --dictionary-select
--dictionary-target ${D}PATH${R} Override dictionary source path (file or directory)
${B}Jellyfin${R}
+42
View File
@@ -50,6 +50,8 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
@@ -435,6 +437,46 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values
);
});
test('parses subtitleStyle.hoverBackground as a hoverTokenBackgroundColor alias', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverBackground": "transparent"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, 'transparent');
});
test('parses subtitleStyle.hoverTokenBackgroundColor null as invalid instead of missing', () => {
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": null
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
);
});
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
+1
View File
@@ -86,6 +86,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
multiCopyTimeoutMs: 3000,
toggleSecondarySub: 'CommandOrControl+Shift+V',
markAudioCard: 'CommandOrControl+Shift+A',
openCharacterDictionary: 'CommandOrControl+Alt+A',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
@@ -117,6 +117,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
jellyfin: {
enabled: false,
serverUrl: '',
recentServers: [],
username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
@@ -265,6 +265,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.serverUrl,
description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
},
{
path: 'jellyfin.recentServers',
kind: 'array',
defaultValue: defaultConfig.jellyfin.recentServers,
description: 'Recently authenticated Jellyfin server URLs shown in setup.',
},
{
path: 'jellyfin.username',
kind: 'string',
+1
View File
@@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openRuntimeOptions',
'openJimaku',
] as const;
+20
View File
@@ -318,6 +318,26 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string array.',
);
}
if (Array.isArray(src.jellyfin.recentServers)) {
const seenRecentServers = new Set<string>();
resolved.jellyfin.recentServers = src.jellyfin.recentServers
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().replace(/\/+$/, ''))
.filter((item) => {
if (!item || seenRecentServers.has(item)) return false;
seenRecentServers.add(item);
return true;
})
.slice(0, 5);
} else if (src.jellyfin.recentServers !== undefined) {
warn(
'jellyfin.recentServers',
src.jellyfin.recentServers,
resolved.jellyfin.recentServers,
'Expected string array.',
);
}
}
if (isObject(src.discordPresence)) {
+28
View File
@@ -17,6 +17,34 @@ test('jellyfin directPlayContainers are normalized', () => {
assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']);
});
test('jellyfin recentServers are normalized, deduped, and capped', () => {
const { context } = createResolveContext({
jellyfin: {
recentServers: [
' http://one.local:8096/ ',
'',
'http://two.local:8096',
'http://one.local:8096',
42 as unknown as string,
'http://three.local:8096',
'http://four.local:8096',
'http://five.local:8096',
'http://six.local:8096',
],
},
});
applyIntegrationConfig(context);
assert.deepEqual(context.resolved.jellyfin.recentServers, [
'http://one.local:8096',
'http://two.local:8096',
'http://three.local:8096',
'http://four.local:8096',
'http://five.local:8096',
]);
});
test('jellyfin legacy auth keys are ignored by resolver', () => {
const { context } = createResolveContext({
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
+11 -8
View File
@@ -260,20 +260,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const hoverTokenBackgroundColor = asString(
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
);
const subtitleStyleSource = src.subtitleStyle as {
hoverBackground?: unknown;
hoverTokenBackgroundColor?: unknown;
};
const rawHoverTokenBackgroundColor =
subtitleStyleSource.hoverTokenBackgroundColor !== undefined
? subtitleStyleSource.hoverTokenBackgroundColor
: subtitleStyleSource.hoverBackground;
const hoverTokenBackgroundColor = asString(rawHoverTokenBackgroundColor);
if (hoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
} else if (
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
undefined
) {
} else if (rawHoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor =
fallbackSubtitleStyleHoverTokenBackgroundColor;
warn(
'subtitleStyle.hoverTokenBackgroundColor',
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
rawHoverTokenBackgroundColor,
resolved.subtitleStyle.hoverTokenBackgroundColor,
'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).',
);
@@ -76,6 +76,32 @@ test('guessAnilistMediaInfo joins multi-part guessit titles', async () => {
});
});
test('guessAnilistMediaInfo preserves useful guessit alternative title for ambiguous Re ZERO filenames', async () => {
const result = await guessAnilistMediaInfo(
'/tmp/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv',
null,
{
runGuessit: async () =>
JSON.stringify({
title: 'Re',
alternative_title: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
}),
},
);
assert.deepEqual(result, {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
});
});
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
+26 -1
View File
@@ -7,6 +7,8 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export interface AnilistMediaGuess {
title: string;
alternativeTitle?: string;
year?: number;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
@@ -131,6 +133,20 @@ function firstPositiveInteger(value: unknown): number | null {
return null;
}
function firstYear(value: unknown): number | undefined {
const candidate = firstPositiveInteger(value);
if (candidate === null) return undefined;
return candidate >= 1900 && candidate <= 2200 ? candidate : undefined;
}
function buildGuessitTitle(title: string, alternativeTitle: string | null): string {
if (!alternativeTitle) return title;
if (title.length <= 3) {
return `${title} ${alternativeTitle}`.replace(/\s+/g, ' ').trim();
}
return title;
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
@@ -215,10 +231,19 @@ export async function guessAnilistMediaInfo(
const stdout = await deps.runGuessit(guessitTarget);
const parsed = JSON.parse(stdout) as Record<string, unknown>;
const title = readGuessitTitle(parsed.title);
const alternativeTitle = readGuessitTitle(parsed.alternative_title);
const episode = firstPositiveInteger(parsed.episode);
const season = firstPositiveInteger(parsed.season);
const year = firstYear(parsed.year);
if (title) {
return { title, season, episode, source: 'guessit' };
return {
title: buildGuessitTitle(title, alternativeTitle),
...(alternativeTitle ? { alternativeTitle } : {}),
...(year ? { year } : {}),
season,
episode,
source: 'guessit',
};
}
} catch {
// Ignore guessit failures and fall back to internal parser.
+7
View File
@@ -6,12 +6,14 @@ import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
setup: false,
show: false,
@@ -37,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -48,6 +51,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: undefined,
stats: false,
jellyfin: false,
jellyfinLogin: false,
@@ -60,6 +66,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
texthookerOpenBrowser: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
+138
View File
@@ -6,6 +6,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
@@ -34,11 +35,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
refreshKnownWords: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -50,6 +53,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: undefined,
stats: false,
jellyfin: false,
jellyfinLogin: false,
@@ -62,6 +68,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
texthookerOpenBrowser: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
@@ -115,6 +122,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
toggleVisibleOverlay: () => {
calls.push('toggleVisibleOverlay');
},
togglePrimarySubtitleBar: () => {
calls.push('togglePrimarySubtitleBar');
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
@@ -199,6 +209,19 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
mediaTitle: 'Test',
entryCount: 10,
}),
getCharacterDictionarySelection: async () => ({
seriesKey: 'test',
guessTitle: 'Test',
current: { id: 1, title: 'Test', episodes: 12 },
override: null,
candidates: [{ id: 1, title: 'Test', episodes: 12 }],
}),
setCharacterDictionarySelection: async () => ({
ok: true,
seriesKey: 'test',
selected: { id: 1, title: 'Test', episodes: 12 },
staleMediaIds: [],
}),
runStatsCommand: async () => {
calls.push('runStatsCommand');
},
@@ -377,6 +400,21 @@ test('handleCliCommand runs texthooker flow with browser open', () => {
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});
test('handleCliCommand opens texthooker browser when requested even if config disables auto-open', () => {
const { deps, calls } = createDeps({
shouldOpenTexthookerBrowser: () => false,
});
const args = {
...makeArgs({ texthooker: true }),
texthookerOpenBrowser: true,
} as CliArgs;
handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});
test('handleCliCommand forwards resolved websocket url to texthooker startup', () => {
const { deps, calls } = createDeps({
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
@@ -516,6 +554,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: 'startPendingMineSentenceMultiple:2500',
},
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
@@ -624,6 +663,105 @@ test('handleCliCommand forwards --dictionary-target to dictionary runtime', asyn
assert.equal(receivedTarget, '/tmp/example-video.mkv');
});
test('handleCliCommand lists character dictionary AniList candidates', async () => {
const { calls, deps } = createDeps({
getCharacterDictionarySelection: async (targetPath?: string) => {
calls.push(`getCharacterDictionarySelection:${targetPath ?? ''}`);
return {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: null },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
{ id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
],
};
},
});
handleCliCommand(
makeArgs({ dictionaryCandidates: true, dictionaryTarget: '/tmp/re-zero.mkv' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('getCharacterDictionarySelection:/tmp/re-zero.mkv'));
assert.ok(
calls.includes(
'log:Character dictionary series key: re-zero-starting-life-in-another-world-2016',
),
);
assert.ok(
calls.includes('log:Candidate: 21355 - Re:ZERO -Starting Life in Another World- (25 episodes)'),
);
});
test('handleCliCommand sets character dictionary manual AniList selection', async () => {
const { calls, deps } = createDeps({
setCharacterDictionarySelection: async (request) => {
calls.push(`setCharacterDictionarySelection:${request.mediaId}:${request.targetPath ?? ''}`);
return {
ok: true,
seriesKey: 're-zero-starting-life-in-another-world-2016',
selected: {
id: request.mediaId,
title: 'Re:ZERO -Starting Life in Another World-',
episodes: 25,
},
staleMediaIds: [10607],
};
},
});
handleCliCommand(
makeArgs({
dictionarySelect: true,
dictionaryAnilistId: 21355,
dictionaryTarget: '/tmp/re-zero.mkv',
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('setCharacterDictionarySelection:21355:/tmp/re-zero.mkv'));
assert.ok(
calls.includes(
'log:Character dictionary override saved: re-zero-starting-life-in-another-world-2016 -> 21355 - Re:ZERO -Starting Life in Another World-',
),
);
});
test('handleCliCommand does not log character dictionary selection success when result is not ok', async () => {
const { calls, deps } = createDeps({
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: 'test',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
});
handleCliCommand(
makeArgs({
dictionarySelect: true,
dictionaryAnilistId: 21355,
dictionaryTarget: '/tmp/re-zero.mkv',
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('warn:Character dictionary override was not saved.'));
assert.equal(
calls.some((call) => call.startsWith('log:Character dictionary override saved:')),
false,
);
});
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },
+124 -1
View File
@@ -1,6 +1,27 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export type CharacterDictionaryCandidate = {
id: number;
title: string;
episodes: number | null;
};
export type CharacterDictionarySelectionSnapshot = {
seriesKey: string;
guessTitle: string | null;
current: CharacterDictionaryCandidate | null;
override: CharacterDictionaryCandidate | null;
candidates: CharacterDictionaryCandidate[];
};
export type CharacterDictionarySelectionResult = {
ok: boolean;
seriesKey: string;
selected: CharacterDictionaryCandidate;
staleMediaIds: number[];
};
export interface CliCommandServiceDeps {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
getMpvSocketPath: () => string;
@@ -19,6 +40,7 @@ export interface CliCommandServiceDeps {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
@@ -64,6 +86,13 @@ export interface CliCommandServiceDeps {
mediaTitle: string;
entryCount: number;
}>;
getCharacterDictionarySelection: (
targetPath?: string,
) => Promise<CharacterDictionarySelectionSnapshot>;
setCharacterDictionarySelection: (request: {
targetPath?: string;
mediaId: number;
}) => Promise<CharacterDictionarySelectionResult>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: {
@@ -110,6 +139,7 @@ interface OverlayCliRuntime {
isInitialized: () => boolean;
initialize: () => void;
toggleVisible: () => void;
togglePrimarySubtitleBar: () => void;
setVisible: (visible: boolean) => void;
}
@@ -162,6 +192,11 @@ export interface CliCommandDepsRuntimeOptions {
mediaTitle: string;
entryCount: number;
}>;
getSelection: (targetPath?: string) => Promise<CharacterDictionarySelectionSnapshot>;
setSelection: (request: {
targetPath?: string;
mediaId: number;
}) => Promise<CharacterDictionarySelectionResult>;
};
jellyfin: {
openSetup: () => void;
@@ -211,6 +246,7 @@ export function createCliCommandDepsRuntime(
isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible,
togglePrimarySubtitleBar: options.overlay.togglePrimarySubtitleBar,
openFirstRunSetup: options.ui.openFirstRunSetup,
openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => {
@@ -237,6 +273,8 @@ export function createCliCommandDepsRuntime(
getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow,
generateCharacterDictionary: options.dictionary.generate,
getCharacterDictionarySelection: options.dictionary.getSelection,
setCharacterDictionarySelection: options.dictionary.setSelection,
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
@@ -267,6 +305,14 @@ function runAsyncWithOsd(
});
}
function formatCandidate(candidate: CharacterDictionaryCandidate): string {
const episodeLabel =
typeof candidate.episodes === 'number' && candidate.episodes > 0
? `${candidate.episodes} episodes`
: 'episodes unknown';
return `${candidate.id} - ${candidate.title} (${episodeLabel})`;
}
export function handleCliCommand(
args: CliArgs,
source: CliCommandSource = 'initial',
@@ -326,6 +372,8 @@ export function handleCliCommand(
if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) {
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
@@ -411,6 +459,12 @@ export function handleCliCommand(
'openSessionHelp',
'Open session help failed',
);
} else if (args.openCharacterDictionary) {
dispatchCliSessionAction(
{ actionId: 'openCharacterDictionary' },
'openCharacterDictionary',
'Open character dictionary failed',
);
} else if (args.openControllerSelect) {
dispatchCliSessionAction(
{ actionId: 'openControllerSelect' },
@@ -546,6 +600,75 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.dictionaryCandidates) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.getCharacterDictionarySelection(args.dictionaryTarget)
.then((selection) => {
deps.log(`Character dictionary series key: ${selection.seriesKey}`);
if (selection.guessTitle) {
deps.log(`Guess: ${selection.guessTitle}`);
}
if (selection.current) {
deps.log(`Current match: ${formatCandidate(selection.current)}`);
}
if (selection.override) {
deps.log(`Manual override: ${formatCandidate(selection.override)}`);
}
for (const candidate of selection.candidates) {
deps.log(`Candidate: ${formatCandidate(candidate)}`);
}
})
.catch((error) => {
deps.error('getCharacterDictionarySelection failed:', error);
deps.warn(
`Character dictionary candidate lookup failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.dictionarySelect) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
if (!args.dictionaryAnilistId) {
deps.warn('--dictionary-select requires --dictionary-anilist-id <ID>.');
if (shouldStopAfterRun) deps.stopApp();
return;
}
deps
.setCharacterDictionarySelection({
targetPath: args.dictionaryTarget,
mediaId: args.dictionaryAnilistId,
})
.then((result) => {
if (!result.ok) {
deps.warn('Character dictionary override was not saved.');
return;
}
deps.log(
`Character dictionary override saved: ${result.seriesKey} -> ${result.selected.id} - ${result.selected.title}`,
);
if (result.staleMediaIds.length > 0) {
deps.log(`Removed stale AniList IDs: ${result.staleMediaIds.join(', ')}`);
}
})
.catch((error) => {
deps.error('setCharacterDictionarySelection failed:', error);
deps.warn(
`Character dictionary override failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.stats) {
void deps.runStatsCommand(args, source);
} else if (args.anilistRetryQueue) {
@@ -581,7 +704,7 @@ export function handleCliCommand(
} else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl());
if (deps.shouldOpenTexthookerBrowser()) {
if (args.texthookerOpenBrowser || deps.shouldOpenTexthookerBrowser()) {
deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`);
}
deps.log(`Texthooker available at http://127.0.0.1:${texthookerPort}`);
+55
View File
@@ -1023,3 +1023,58 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
});
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: number[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
getCharacterDictionarySelection: async () => ({
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
],
}),
setCharacterDictionarySelection: async (mediaId) => {
calls.push(mediaId);
return {
ok: true,
seriesKey: 're-zero-starting-life-in-another-world-2016',
selected: {
id: mediaId,
title: 'Re:ZERO -Starting Life in Another World-',
episodes: 25,
},
staleMediaIds: [10607],
};
},
}),
registrar,
);
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
assert.deepEqual(await getHandler!({}), {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
});
assert.deepEqual(await setHandler!({}, 0), {
ok: false,
message: 'Invalid AniList media ID.',
});
assert.deepEqual(await setHandler!({}, 21355), {
ok: true,
seriesKey: 're-zero-starting-life-in-another-world-2016',
selected: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
staleMediaIds: [10607],
});
assert.deepEqual(calls, [21355]);
});
+46
View File
@@ -90,6 +90,8 @@ export interface IpcServiceDeps {
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
getCharacterDictionarySelection?: () => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
@@ -211,6 +213,8 @@ export interface IpcDepsRuntimeOptions {
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
getCharacterDictionarySelection?: () => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
@@ -284,6 +288,23 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
openAnilistSetup: options.openAnilistSetup,
getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow,
getCharacterDictionarySelection:
options.getCharacterDictionarySelection ??
(async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
})),
setCharacterDictionarySelection:
options.setCharacterDictionarySelection ??
(async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
})),
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
@@ -570,6 +591,31 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.retryAnilistQueueNow();
});
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
return await (deps.getCharacterDictionarySelection?.() ??
Promise.resolve({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}));
});
ipc.handle(
IPC_CHANNELS.request.setCharacterDictionarySelection,
async (_event, mediaId: unknown) => {
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
return { ok: false, message: 'Invalid AniList media ID.' };
}
return await (deps.setCharacterDictionarySelection?.(mediaId as number) ??
Promise.resolve({
ok: false,
message: 'Character dictionary selection unavailable.',
}));
},
);
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
return deps.appendClipboardVideoToQueue();
});
+18
View File
@@ -19,6 +19,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
commands: unknown[];
mediaPath: string;
restored: number;
quitRequested: number;
};
} {
const state = {
@@ -28,6 +29,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
commands: [] as unknown[],
mediaPath: '',
restored: 0,
quitRequested: 0,
};
const metrics: MpvSubtitleRenderMetrics = {
subPos: 100,
@@ -102,6 +104,10 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
shouldQuitOnMpvShutdown: () => false,
requestAppQuit: () => {
state.quitRequested += 1;
},
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
@@ -223,6 +229,18 @@ test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', asy
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
assert.equal(state.quitRequested, 0);
});
test('dispatchMpvProtocolMessage quits app on managed playback shutdown', async () => {
const { deps, state } = createDeps({
shouldQuitOnMpvShutdown: () => true,
});
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
assert.equal(state.quitRequested, 1);
});
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {
+5
View File
@@ -91,6 +91,8 @@ export interface MpvProtocolHandleMessageDeps {
) => void;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
restorePreviousSecondarySubVisibility: () => void;
shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void;
}
type SubtitleTrackCandidate = {
@@ -360,6 +362,9 @@ export async function dispatchMpvProtocolMessage(
}
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) {
deps.requestAppQuit();
}
} else if (msg.request_id) {
if (deps.resolvePendingRequest(msg.request_id, msg)) {
return;
+19
View File
@@ -285,6 +285,25 @@ test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect
assert.equal(timers.length, 1);
});
test('MpvIpcClient onClose requests app quit for managed playback', () => {
let quitRequests = 0;
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
shouldQuitOnMpvShutdown: () => true,
requestAppQuit: () => {
quitRequests += 1;
},
}),
);
(client as any).scheduleReconnect = () => {};
(client as any).transport.callbacks.onClose();
assert.equal(quitRequests, 1);
});
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+8
View File
@@ -105,6 +105,8 @@ export interface MpvIpcClientProtocolDeps {
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
}
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
@@ -217,6 +219,10 @@ export class MpvIpcClient implements MpvClient {
this.playbackPaused = null;
this.emit('connection-change', { connected: false });
this.failPendingRequests();
if (this.deps.shouldQuitOnMpvShutdown?.() === true) {
this.deps.requestAppQuit?.();
return;
}
this.scheduleReconnect();
},
});
@@ -399,6 +405,8 @@ export class MpvIpcClient implements MpvClient {
restorePreviousSecondarySubVisibility: () => {
this.restorePreviousSecondarySubVisibility();
},
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => this.deps.requestAppQuit?.(),
};
}

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