mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e241aa8c86
|
|||
|
25d0aa47db
|
|||
| db30c61327 | |||
|
27f5b2bb58
|
|||
|
baabdb6d30
|
|||
|
3a67e23bc3
|
|||
|
13e2b5f8c8
|
|||
| 53aa58d044 | |||
| d8934647a9 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+44
@@ -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 -->
|
||||
+43
@@ -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 -->
|
||||
+54
@@ -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 -->
|
||||
+32
@@ -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 -->
|
||||
+54
@@ -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 -->
|
||||
+55
@@ -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 -->
|
||||
+53
@@ -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 -->
|
||||
+54
@@ -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 -->
|
||||
+58
@@ -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,38 @@
|
||||
---
|
||||
id: TASK-325
|
||||
title: Fix session chart known-word percentage denominator
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-05-04 01:19'
|
||||
updated_date: '2026-05-04 01:23'
|
||||
labels:
|
||||
- stats
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Session detail known-word percentages should use the same filtered vocabulary occurrence rows for both known and total word counts. Current chart can divide known persisted word occurrences by raw token totals, causing excluded tokens to depress the known percentage.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Session known-word timeline API exposes cumulative filtered total word counts alongside known counts.
|
||||
- [x] #2 Session detail chart computes known/unknown areas from filtered totals, not raw timeline token counts, when known-word data is available.
|
||||
- [x] #3 Session summary known-word rate uses filtered persisted word totals where available and preserves safe fallback behavior when known-word data is unavailable.
|
||||
- [x] #4 Regression tests cover filtered denominator behavior for the API and chart data path.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented in-place fix using existing persisted word occurrence rows. `/api/stats/sessions/:id/known-words-timeline` now returns cumulative `totalWordsSeen` from filtered persisted occurrences, and session known-word rates divide by the same filtered total. Session detail chart builds known/unknown areas from `totalWordsSeen` instead of raw timeline `tokensSeen`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Known-word percentages on session charts now use filtered persisted word totals for both numerator and denominator. No migration/backfill required; data comes from existing `imm_word_line_occurrences`. Added regression coverage for the API response/rate and chart data builder.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: TASK-326
|
||||
title: Make stats word metrics honor filtering rules
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-05-04 01:35'
|
||||
updated_date: '2026-05-04 02:08'
|
||||
labels:
|
||||
- stats
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Audit stats app metrics that show or derive from word totals and make them use filtered persisted vocabulary occurrences where the UI concept is learned/seen words. Preserve raw telemetry only where it is intentionally playback/token telemetry.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Stats UI word totals, word rates, lookup-per-word rates, and chart word series use filtered persisted word occurrences where available.
|
||||
- [x] #2 Known-word metrics continue to use the same filtered denominator as known counts.
|
||||
- [x] #3 Trend, overview, library, session, and episode surfaces are audited with regression coverage for changed data paths.
|
||||
- [x] #4 Fallback behavior remains safe for sessions without persisted vocabulary occurrences.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Audit finding: raw `tokensSeen` / `totalTokensSeen` still feeds overview hints, dashboard aggregation, trends activity/progress/anime cumulative/library summary, lookup-per-100-word rates, session rows/recent sessions/episode sessions, and library/anime/media headers. Vocabulary and known unique word summaries already use persisted filtered vocabulary rows. Recommended design: query-time filtered word totals from existing `imm_word_line_occurrences`, with raw-token fallback only when a session has no persisted occurrence rows.
|
||||
|
||||
Implemented shared query-time filtered word counts. Session summaries, overview hints, daily/monthly rollups, anime/media library/detail rows, anime episode rows, episode/media sessions, trends activity/progress/anime cumulative, library summary, and lookup-per-100-word ratios now use filtered persisted word occurrences. Fallback remains raw token totals only for sessions with no persisted subtitle-line rows.
|
||||
|
||||
Follow-up implemented: Vocab frequency tables now apply the same tokenizer vocabulary predicate at read time, because old `imm_words` rows can predate current tokenizer exclusion rules. Vocabulary persistence and cleanup also mirror the broader subtitle-annotation grammar filters. Added common frequency stop terms observed in the stats vocabulary list to the shared tokenizer exclusion set so those rows are filtered consistently across subtitle annotations, persistence, cleanup, stats reads, and SQL word-count aggregates.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Stats word metrics now honor filtering rules through the read-model query layer. Existing persisted `imm_word_line_occurrences` provide the filtered denominator; no migration/backfill needed. Vocab tables filter stored rows on read using tokenizer vocabulary rules, so legacy noisy rows stop appearing without a migration. Added regressions for session/overview/rollup fallback behavior, trends/library lookup-rate behavior, vocabulary read filtering, cleanup filtering, and shared stop-term filtering.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: TASK-327
|
||||
title: Persist stats page exclusion list in database
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-05-04 01:39'
|
||||
updated_date: '2026-05-04 01:49'
|
||||
labels:
|
||||
- feature
|
||||
- stats
|
||||
- database
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add database-backed persistence for the stats page exclusion list. On first load with the new schema, seed the new table from the existing exclusion list source so existing user choices are preserved. After migration, update database rows whenever the exclusion list is changed or saved so it persists across browser sessions indefinitely.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 A new small database table stores stats page exclusion entries.
|
||||
- [x] #2 First load with the new schema seeds the table from the existing exclusion list source.
|
||||
- [x] #3 Subsequent exclusion list save/change operations update the database-backed list.
|
||||
- [x] #4 Regression coverage verifies migration/seed behavior and persistence updates.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented DB-backed stats exclusion list using schema version 18 and new `imm_stats_excluded_words` table. Added read/replace query helpers, service methods, and `/api/stats/excluded-words` GET/PUT routes. Stats frontend now loads exclusions from DB, seeds the empty DB table from legacy `localStorage` on first load, and writes each toggle/restore/clear through the API while keeping localStorage in sync for compatibility. Added focused regression coverage for schema/read-replace, API routes, API client, and frontend bootstrap/update behavior. Verification: `bun run typecheck` passed; `bun test src/core/services/__tests__/stats-server.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts` passed; `bun test src/core/services/immersion-tracker/storage-session.test.ts` passed; `bun run docs:test` passed; `bun run format:check:stats` passed; `bun run changelog:lint` passed. Blocked/unrelated: `bun run typecheck:stats` fails in existing stats files (`AnilistSelector.tsx`, `reading-utils*`, `session-grouping.test.ts`, `yomitan-lookup.test.tsx`); `bun run test:immersion:sqlite:src` fails existing `recordSubtitleLine counts exact Yomitan tokens for session metrics` expected 4 got 3; `bun run docs:build` fails missing `@catppuccin/vitepress/theme/macchiato/mauve.css` import.
|
||||
|
||||
Added `src/core/services/__tests__/stats-server.test.ts` and `stats/src/hooks/useExcludedWords.test.ts` to the `test:core:src` allowlist so the new DB exclusion route/client/store regressions run in the maintained fast source lane.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Persisted the stats vocabulary exclusion list in SQLite with new schema version 18 table `imm_stats_excluded_words`. Added backend read/replace helpers and `/api/stats/excluded-words` GET/PUT routes, then wired the stats frontend exclusion store to load DB rows, seed an empty DB from legacy browser localStorage on first load, and update the DB on toggle/restore/clear. Updated docs and added changelog fragment. Focused tests and root typecheck pass; broader stats/docs/sqlite gates are blocked by unrelated existing failures recorded in notes.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-329
|
||||
title: Keep JLPT subtitle styling underline-only
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-05-04 02:13'
|
||||
labels:
|
||||
- bug
|
||||
- renderer
|
||||
- jlpt
|
||||
dependencies: []
|
||||
references:
|
||||
- src/renderer/style.css
|
||||
- src/renderer/subtitle-render.test.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Fix subtitle token styling so JLPT metadata never changes token text color. JLPT should only render the level marker/underline affordance while known, n+1, name-match, and frequency colors retain priority.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 JLPT-only subtitle tokens do not set token text color.
|
||||
- [x] #2 JLPT level marker/underline still uses configured JLPT color.
|
||||
- [x] #3 Existing known, n+1, name-match, and frequency text colors remain unchanged.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Changed subtitle JLPT styling from text color to underline decoration and updated renderer CSS regression coverage.
|
||||
|
||||
Verification:
|
||||
- `bun test src/renderer/subtitle-render.test.ts`
|
||||
- `bunx prettier --check src/renderer/subtitle-render.test.ts src/renderer/style.css`
|
||||
- `bun run typecheck`
|
||||
|
||||
Blocked:
|
||||
- `bun run test:fast` fails in existing dirty stats/session work: `recordSubtitleLine counts exact Yomitan tokens for session metrics` expects `tokensSeen` 4 but gets 3.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: TASK-330
|
||||
title: Fix PR 60 CI failures and CodeRabbit feedback
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-05-04 02:50'
|
||||
updated_date: '2026-05-04 02:59'
|
||||
labels:
|
||||
- ci
|
||||
- pr-review
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://github.com/ksyasuda/SubMiner/pull/60'
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Resolve failing GitHub Actions checks and actionable unresolved CodeRabbit review feedback on PR #60 (Persist stats exclusions in DB and fix word metrics filtering). Keep fixes scoped to the PR behavior and preserve existing project patterns.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Failing GitHub Actions checks for PR #60 have an identified root cause and local fix.
|
||||
- [x] #2 All actionable unresolved CodeRabbit review comments on PR #60 are addressed locally or explicitly documented as non-actionable.
|
||||
- [x] #3 Relevant local verification passes for the changed code paths.
|
||||
- [x] #4 Task notes summarize CI failure context, review-comment handling, and any residual verification gaps.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Resolve PR #60 context and inspect GitHub Actions failures with the gh-fix-ci workflow.
|
||||
2. Fetch unresolved review threads with the gh-address-comments workflow, focusing on CodeRabbit actionable comments.
|
||||
3. Read the touched files/tests around the failing paths and comments; identify root cause before edits.
|
||||
4. Apply minimal fixes with regression coverage where appropriate.
|
||||
5. Run targeted verification first, then broader repo gates as time permits.
|
||||
6. Update Backlog notes/acceptance criteria with CI/comment outcomes and residual risks.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Resolved PR #60 CI failure by restoring raw `tokensSeen` for session summaries while keeping filtered persisted word counts in aggregate/known-word paths. Addressed CodeRabbit feedback: fixed missing `headword` test fixture binding; paged vocabulary stats past filtered rows; preserved lifetime/rollup totals when retained-session recomputation is partial; emitted flat known-word timeline points for zero-visible-word line gaps; restored localStorage mocks; added rollback/retry behavior for excluded-word store persistence/initialization.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed the PR #60 CI failure and addressed actionable CodeRabbit feedback.
|
||||
|
||||
Key changes:
|
||||
- Restored exact Yomitan token counts for session summary metrics while leaving filtered word counts for aggregate and known-word calculations.
|
||||
- Fixed malformed query test fixtures by binding `headword` into `imm_words` inserts.
|
||||
- Updated vocabulary stats to page until enough visible rows are collected after post-query filtering.
|
||||
- Made library/detail/rollup read models preserve lifetime or stored rollup totals when retained-session recomputation is partial, including dashboard rollup-derived word metrics.
|
||||
- Kept known-word timeline line positions stable by emitting flat points for missing line indexes.
|
||||
- Made excluded-word persistence rollback on failed writes, allow initialization retries after transient load failures, and restored mocked `localStorage` in tests.
|
||||
|
||||
Verification passed:
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
- `bun run test:smoke:dist`
|
||||
- `bun run format:check:src`
|
||||
- `git diff --check`
|
||||
<!-- 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Stats vocabulary exclusions now persist in the immersion database and import existing browser-local exclusions on first load.
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,18 +47,20 @@ 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 |
|
||||
| --- | --- |
|
||||
| --------- | ---------- |
|
||||
| さん | さん |
|
||||
| 様 | さま |
|
||||
| 先生 | せんせい |
|
||||
@@ -93,7 +95,7 @@ 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 |
|
||||
|
||||
@@ -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
|
||||
@@ -195,7 +221,7 @@ 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 |
|
||||
@@ -212,7 +238,7 @@ 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 |
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,6 +560,7 @@ 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"`) |
|
||||
@@ -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) |
|
||||
|
||||
@@ -855,7 +858,7 @@ 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) |
|
||||
@@ -890,7 +893,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `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.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`) |
|
||||
@@ -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.
|
||||
|
||||
@@ -102,7 +102,7 @@ Secondary subtitle text (typically English translations) is stored alongside pri
|
||||
|
||||
### Word Exclusion List
|
||||
|
||||
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in browser localStorage and can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
|
||||
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
|
||||
|
||||
## Retention Defaults
|
||||
|
||||
|
||||
@@ -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 ...`
|
||||
|
||||
@@ -70,7 +70,7 @@ 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) |
|
||||
@@ -82,7 +82,10 @@ subminer stats -b # start background stats daemon
|
||||
| `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.
|
||||
@@ -90,7 +93,7 @@ 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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,8 +36,9 @@ 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 |
|
||||
| `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 |
|
||||
@@ -64,8 +65,9 @@ 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+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` |
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
|
||||
@@ -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
@@ -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/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/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 stats/src/hooks/useExcludedWords.test.ts",
|
||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core: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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
+180
-47
@@ -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',
|
||||
];
|
||||
|
||||
const SECTION_HEADER_PATTERN = /^### (Breaking Changes|Added|Changed|Fixed|Docs|Internal)$/m;
|
||||
|
||||
const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release changelog for end users of SubMiner, an Electron app for Japanese sentence mining.
|
||||
|
||||
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.
|
||||
|
||||
## 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.",
|
||||
);
|
||||
}
|
||||
throw new Error(`claude CLI invocation failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string {
|
||||
return `- ${formatAreaLabel(fragment.area)}: ${bullet.replace(/^- /, '')}`;
|
||||
function serializeFragmentsForPrompt(
|
||||
fragments: ChangeFragment[],
|
||||
mode: PolishMode,
|
||||
version: string,
|
||||
date?: string,
|
||||
): string {
|
||||
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
|
||||
if (date) {
|
||||
header.push(`DATE: ${date}`);
|
||||
}
|
||||
|
||||
function renderGroupedChanges(fragments: ChangeFragment[]): string {
|
||||
const sections: string[] = [];
|
||||
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');
|
||||
});
|
||||
|
||||
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}`);
|
||||
return [...header, '', ...fragmentBlocks].join('\n\n');
|
||||
}
|
||||
|
||||
for (const type of CHANGE_TYPES) {
|
||||
const typeFragments = fragments.filter((fragment) => fragment.type === type);
|
||||
if (typeFragments.length === 0) {
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
|
||||
const bullets = typeFragments
|
||||
.flatMap((fragment) =>
|
||||
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
|
||||
)
|
||||
.join('\n');
|
||||
sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
|
||||
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/.',
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
const prompt =
|
||||
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
|
||||
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
|
||||
return validatePolishedOutput(output, mode, hasInternalFragments);
|
||||
}
|
||||
|
||||
function buildReleaseSection(version: string, date: string, fragments: ChangeFragment[]): string {
|
||||
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.',
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 ||
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
] as const;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()).',
|
||||
);
|
||||
|
||||
@@ -277,6 +277,8 @@ function createMockTracker(
|
||||
getSessionTimeline: async () => [],
|
||||
getSessionEvents: async () => [],
|
||||
getVocabularyStats: async () => VOCABULARY_STATS,
|
||||
getStatsExcludedWords: async () => [],
|
||||
replaceStatsExcludedWords: async () => {},
|
||||
getKanjiStats: async () => KANJI_STATS,
|
||||
getWordOccurrences: async () => OCCURRENCES,
|
||||
getKanjiOccurrences: async () => OCCURRENCES,
|
||||
@@ -362,7 +364,7 @@ describe('stats server API routes', () => {
|
||||
assert.ok(Array.isArray(body));
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions enriches each session with known-word metrics when cache exists', async () => {
|
||||
it('GET /api/stats/sessions enriches known-word metrics using filtered persisted totals', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const cachePath = path.join(dir, 'known-words.json');
|
||||
fs.writeFileSync(
|
||||
@@ -391,7 +393,7 @@ describe('stats server API routes', () => {
|
||||
const body = await res.json();
|
||||
const first = body[0];
|
||||
assert.equal(first.knownWordsSeen, 2);
|
||||
assert.equal(first.knownWordRate, 2.5);
|
||||
assert.equal(first.knownWordRate, 66.7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,7 +438,7 @@ describe('stats server API routes', () => {
|
||||
assert.equal(seenLimit, undefined);
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts known occurrences', async () => {
|
||||
it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts filtered totals', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const cachePath = path.join(dir, 'known-words.json');
|
||||
fs.writeFileSync(
|
||||
@@ -461,8 +463,10 @@ describe('stats server API routes', () => {
|
||||
const res = await app.request('/api/stats/sessions/1/known-words-timeline');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), [
|
||||
{ linesSeen: 1, knownWordsSeen: 2 },
|
||||
{ linesSeen: 3, knownWordsSeen: 3 },
|
||||
{ linesSeen: 0, knownWordsSeen: 0, totalWordsSeen: 0 },
|
||||
{ linesSeen: 1, knownWordsSeen: 2, totalWordsSeen: 2 },
|
||||
{ linesSeen: 2, knownWordsSeen: 2, totalWordsSeen: 2 },
|
||||
{ linesSeen: 3, knownWordsSeen: 3, totalWordsSeen: 7 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -730,6 +734,65 @@ describe('stats server API routes', () => {
|
||||
assert.equal(body[0].pos3, null);
|
||||
});
|
||||
|
||||
it('GET /api/stats/excluded-words returns tracker exclusion rows', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getStatsExcludedWords: async () => [
|
||||
{ headword: '猫', word: '猫', reading: 'ねこ' },
|
||||
{ headword: 'する', word: 'する', reading: 'する' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/excluded-words');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), [
|
||||
{ headword: '猫', word: '猫', reading: 'ねこ' },
|
||||
{ headword: 'する', word: 'する', reading: 'する' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => {
|
||||
let seenWords: unknown = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
replaceStatsExcludedWords: async (words: unknown) => {
|
||||
seenWords = words;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/excluded-words', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
words: [
|
||||
{ headword: '猫', word: '猫', reading: 'ねこ' },
|
||||
{ headword: 'する', word: 'する', reading: 'する' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
assert.deepEqual(seenWords, [
|
||||
{ headword: '猫', word: '猫', reading: 'ねこ' },
|
||||
{ headword: 'する', word: 'する', reading: 'する' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
|
||||
const res = await app.request('/api/stats/excluded-words', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ words: [{ headword: '猫', word: 7, reading: 'ねこ' }] }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime returns anime library', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anime');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user