From 055bd76718a0274203dc1f2aaf62eaddf448e496 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 25 Apr 2026 15:53:20 -0700 Subject: [PATCH] feat: add manual AniList selection for character dictionaries --- ...ion-for-character-dictionary-resolution.md | 43 ++++ changes/291-character-dictionary-selection.md | 5 + config.example.jsonc | 1 + docs-site/character-dictionary.md | 122 ++++++---- docs-site/configuration.md | 7 +- docs-site/launcher-script.md | 60 ++--- docs-site/public/config.example.jsonc | 1 + docs-site/shortcuts.md | 63 ++--- docs-site/usage.md | 6 + launcher/commands/command-modules.test.ts | 38 ++- launcher/commands/dictionary-command.ts | 15 +- launcher/commands/playback-command.test.ts | 2 + launcher/config/args-normalizer.test.ts | 3 + launcher/config/args-normalizer.ts | 21 ++ launcher/config/cli-parser-builder.ts | 22 +- launcher/main.test.ts | 35 ++- launcher/mpv.test.ts | 2 + launcher/parse-args.test.ts | 11 + launcher/types.ts | 3 + src/cli/args.test.ts | 12 + src/cli/args.ts | 31 ++- src/cli/help.ts | 4 + src/config/config.test.ts | 2 + src/config/definitions/defaults-core.ts | 1 + src/config/resolve/core-domains.ts | 1 + .../services/anilist/anilist-updater.test.ts | 26 ++ src/core/services/anilist/anilist-updater.ts | 27 ++- src/core/services/app-lifecycle.test.ts | 4 + src/core/services/cli-command.test.ts | 88 +++++++ src/core/services/cli-command.ts | 114 +++++++++ src/core/services/ipc.test.ts | 55 +++++ src/core/services/ipc.ts | 46 ++++ .../services/overlay-shortcut-handler.test.ts | 14 ++ src/core/services/overlay-shortcut-handler.ts | 12 + src/core/services/overlay-shortcut.test.ts | 4 + src/core/services/overlay-shortcut.ts | 2 + src/core/services/session-actions.ts | 4 + src/core/services/session-bindings.test.ts | 1 + src/core/services/session-bindings.ts | 1 + src/core/services/startup-bootstrap.test.ts | 4 + src/core/utils/shortcut-config.test.ts | 2 + src/core/utils/shortcut-config.ts | 4 + src/main.ts | 29 +++ src/main/character-dictionary-runtime.ts | 168 ++++++++++--- .../character-dictionary-runtime/fetch.ts | 87 +++++++ .../manual-selection.test.ts | 81 +++++++ .../manual-selection.ts | 122 ++++++++++ .../character-dictionary-runtime/types.ts | 23 ++ src/main/cli-runtime.ts | 4 + src/main/dependencies.ts | 8 + src/main/overlay-shortcuts-runtime.ts | 4 + .../character-dictionary-auto-sync.test.ts | 63 +++++ .../runtime/character-dictionary-auto-sync.ts | 9 +- src/main/runtime/character-dictionary-open.ts | 48 ++++ src/main/runtime/cli-command-context-deps.ts | 4 + .../runtime/cli-command-context-main-deps.ts | 4 + src/main/runtime/cli-command-context.ts | 19 ++ .../runtime/first-run-setup-service.test.ts | 4 + .../global-shortcuts-runtime-handlers.test.ts | 1 + src/main/runtime/global-shortcuts.test.ts | 1 + ...verlay-shortcuts-runtime-main-deps.test.ts | 3 + .../overlay-shortcuts-runtime-main-deps.ts | 1 + src/preload.ts | 8 + src/renderer/handlers/keyboard.test.ts | 1 + src/renderer/handlers/keyboard.ts | 5 + src/renderer/index.html | 14 ++ .../modals/character-dictionary.test.ts | 144 +++++++++++ src/renderer/modals/character-dictionary.ts | 224 ++++++++++++++++++ src/renderer/modals/session-help.ts | 1 + src/renderer/renderer.ts | 18 ++ src/renderer/state.ts | 11 + src/renderer/style.css | 65 +++++ src/renderer/utils/dom.ts | 16 ++ src/shared/ipc/contracts.ts | 4 + src/shared/ipc/validators.ts | 1 + src/types/config.ts | 1 + src/types/runtime.ts | 30 ++- src/types/session-bindings.ts | 1 + 78 files changed, 1986 insertions(+), 160 deletions(-) create mode 100644 backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md create mode 100644 changes/291-character-dictionary-selection.md create mode 100644 src/main/character-dictionary-runtime/manual-selection.test.ts create mode 100644 src/main/character-dictionary-runtime/manual-selection.ts create mode 100644 src/main/runtime/character-dictionary-open.ts create mode 100644 src/renderer/modals/character-dictionary.test.ts create mode 100644 src/renderer/modals/character-dictionary.ts diff --git a/backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md b/backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md new file mode 100644 index 00000000..582bcf69 --- /dev/null +++ b/backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + diff --git a/changes/291-character-dictionary-selection.md b/changes/291-character-dictionary-selection.md new file mode 100644 index 00000000..72410716 --- /dev/null +++ b/changes/291-character-dictionary-selection.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 92474731..7632da1e 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 26d82277..0bfdfb15 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -28,9 +28,9 @@ Character dictionary sync is disabled by default. To turn it on: "enabled": true, "accessToken": "your-token", "characterDictionary": { - "enabled": true - } - } + "enabled": true, + }, + }, } ``` @@ -47,33 +47,35 @@ If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for: **Spacing and combination:** + - Full name with space: 須々木 心一 - Combined form: 須々木心一 - Family name alone: 須々木 - Given name alone: 心一 **Middle-dot removal** (common in katakana foreign names): + - ア・リ・ス → アリス (combined), plus individual segments **Honorific suffixes** — each base name is expanded with 15 common suffixes: -| Honorific | Reading | -| --- | --- | -| さん | さん | -| 様 | さま | -| 先生 | せんせい | -| 先輩 | せんぱい | -| 後輩 | こうはい | -| 氏 | し | -| 君 | くん | -| くん | くん | -| ちゃん | ちゃん | -| たん | たん | -| 坊 | ぼう | -| 殿 | どの | -| 博士 | はかせ | -| 社長 | しゃちょう | -| 部長 | ぶちょう | +| Honorific | Reading | +| --------- | ---------- | +| さん | さん | +| 様 | さま | +| 先生 | せんせい | +| 先輩 | せんぱい | +| 後輩 | こうはい | +| 氏 | し | +| 君 | くん | +| くん | くん | +| ちゃん | ちゃん | +| たん | たん | +| 坊 | ぼう | +| 殿 | どの | +| 博士 | はかせ | +| 社長 | しゃちょう | +| 部長 | ぶちょう | **Romanized names** — names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text. @@ -92,10 +94,10 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting, **Key settings:** -| Option | Default | Description | -| --- | --- | --- | -| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting | -| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | +| Option | Default | Description | +| -------------------------------- | --------- | ---------------------------------- | +| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting | +| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | ## Dictionary Entries @@ -117,10 +119,10 @@ The three collapsible sections can be configured to start open or closed: "collapsibleSections": { "description": false, "characterInformation": false, - "voicedBy": false - } - } - } + "voicedBy": false, + }, + }, + }, } ``` @@ -143,7 +145,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine { "activeMediaIds": [170942, 163134, 154587], "mergedRevision": "a1b2c3d4e5f6", - "mergedDictionaryTitle": "SubMiner Character Dictionary" + "mergedDictionaryTitle": "SubMiner Character Dictionary", } ``` @@ -163,6 +165,29 @@ SubMiner.AppImage --dictionary This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots. +## Correcting AniList Matches + +SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series. + +Use the in-app selector or CLI to pin the correct AniList media for the whole series: + +```bash +# List candidate AniList matches for a file +subminer dictionary --candidates "/path/to/episode.mkv" + +# Save the correct AniList media ID for that series +subminer dictionary --select 21355 "/path/to/episode.mkv" + +# Equivalent direct app flags +SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv" +SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv" + +# Open the in-app selector from the running app +subminer app --open-character-dictionary +``` + +Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. + ## File Structure All character dictionary data lives under `{userData}/character-dictionaries/`: @@ -174,6 +199,7 @@ character-dictionaries/ anilist-163134.json merged.zip # Active merged dictionary (imported into Yomitan) auto-sync-state.json # Tracks active media IDs and revision + anilist-overrides.json # Manual series-to-AniList overrides img/ m170942-c12345.jpg # Character portrait m170942-va67890.jpg # Voice actor portrait @@ -194,16 +220,16 @@ merged.zip ## Configuration Reference -| Option | Default | Description | -| --- | --- | --- | -| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList | -| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | -| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | -| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | -| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | -| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | -| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles | -| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | +| Option | Default | Description | +| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- | +| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList | +| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | +| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | +| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | +| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | +| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | +| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles | +| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | ## Reference Implementation @@ -211,14 +237,14 @@ SubMiner's character dictionary builder is inspired by the [Japanese Character N The reference implementation covers similar ground — name variant generation, honorific expansion, structured Yomitan content, portrait embedding — and additionally supports VNDB as a data source for visual novel characters. Key differences: -| | SubMiner | Reference Implementation | -| --- | --- | --- | -| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service | -| **Data sources** | AniList only | AniList + VNDB | -| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI | -| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export | -| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) | -| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh | +| | SubMiner | Reference Implementation | +| ---------------------- | -------------------------------------------- | ------------------------------------- | +| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service | +| **Data sources** | AniList only | AniList + VNDB | +| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI | +| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export | +| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) | +| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh | If you work with visual novels or want a standalone dictionary generator independent of SubMiner, the reference implementation is worth checking out. @@ -226,7 +252,7 @@ If you work with visual novels or want a standalone dictionary generator indepen - **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. -- **Wrong characters showing:** The merged dictionary includes your `maxLoaded` most recent titles. If you're seeing names from a previous show, they'll rotate out once you watch enough new titles to push it past the limit. +- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id `. 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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index ea28a111..36f54e8b 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -535,6 +535,7 @@ See `config.example.jsonc` for detailed configuration options. "mineSentence": "CommandOrControl+S", "mineSentenceMultiple": "CommandOrControl+Shift+S", "markAudioCard": "CommandOrControl+Shift+A", + "openCharacterDictionary": "CommandOrControl+Alt+A", "openRuntimeOptions": "CommandOrControl+Shift+O", "openSessionHelp": "CommandOrControl+Shift+H", "openControllerSelect": "Alt+C", @@ -559,10 +560,11 @@ See `config.example.jsonc` for detailed configuration options. | `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | +| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | | `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) | -| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | -| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | | `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | @@ -689,6 +691,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | +| `Ctrl+Alt+A` | Open character dictionary AniList selector | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index c6813b07..0b8cb7a8 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -69,40 +69,42 @@ subminer stats -b # start background stats daemon ## Subcommands -| Subcommand | Purpose | -| ---------------------------- | ---------------------------------------------------------- | -| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | -| `subminer stats` | Start stats server and open immersion dashboard in browser | -| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) | -| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | -| `subminer doctor` | Dependency + config + socket diagnostics | -| `subminer config path` | Print active config file path | -| `subminer config show` | Print active config contents | -| `subminer mpv status` | Check mpv socket readiness | -| `subminer mpv socket` | Print active socket path | -| `subminer mpv idle` | Launch detached idle mpv instance | -| `subminer dictionary ` | Generate character dictionary ZIP from file/dir target | -| `subminer texthooker` | Launch texthooker-only mode | -| `subminer app` | Pass arguments directly to SubMiner binary | +| Subcommand | Purpose | +| ------------------------------------------ | ------------------------------------------------------------------ | +| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | +| `subminer stats` | Start stats server and open immersion dashboard in browser | +| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) | +| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | +| `subminer doctor` | Dependency + config + socket diagnostics | +| `subminer config path` | Print active config file path | +| `subminer config show` | Print active config contents | +| `subminer mpv status` | Check mpv socket readiness | +| `subminer mpv socket` | Print active socket path | +| `subminer mpv idle` | Launch detached idle mpv instance | +| `subminer dictionary ` | Generate character dictionary ZIP from file/dir target | +| `subminer dictionary --candidates ` | List AniList candidate matches for character dictionary correction | +| `subminer dictionary --select ` | Pin an AniList media ID for that target series | +| `subminer texthooker` | Launch texthooker-only mode | +| `subminer app` | Pass arguments directly to SubMiner binary | Use `subminer -h` for command-specific help. ## Options -| Flag | Description | -| --------------------- | --------------------------------------------------- | -| `-d, --directory` | Video search directory (default: cwd) | -| `-r, --recursive` | Search directories recursively | -| `-R, --rofi` | Use rofi instead of fzf | -| `--setup` | Open first-run setup popup manually | -| `--start` | Explicitly start overlay after mpv launches | -| `-S, --start-overlay` | Explicitly start overlay after mpv launches | -| `-T, --no-texthooker` | Disable texthooker server | -| `-p, --profile` | mpv profile name (no default; omitted unless set) | -| `-a, --args` | Pass additional mpv arguments as a quoted string | -| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) | -| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | -| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | +| Flag | Description | +| --------------------- | -------------------------------------------------------------------- | +| `-d, --directory` | Video search directory (default: cwd) | +| `-r, --recursive` | Search directories recursively | +| `-R, --rofi` | Use rofi instead of fzf | +| `--setup` | Open first-run setup popup manually | +| `--start` | Explicitly start overlay after mpv launches | +| `-S, --start-overlay` | Explicitly start overlay after mpv launches | +| `-T, --no-texthooker` | Disable texthooker server | +| `-p, --profile` | mpv profile name (no default; omitted unless set) | +| `-a, --args` | Pass additional mpv arguments as a quoted string | +| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) | +| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | +| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 92474731..7632da1e 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index a79ee7a0..d841ec45 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -35,27 +35,27 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu These control playback and subtitle display. They require overlay window focus. -| Shortcut | Action | -| -------------------- | -------------------------------------------------- | -| `Space` | Toggle mpv pause | -| `J` | Cycle primary subtitle track | -| `Shift+J` | Cycle secondary subtitle track | +| Shortcut | Action | +| -------------------- | --------------------------------------------------- | +| `Space` | Toggle mpv pause | +| `J` | Cycle primary subtitle track | +| `Shift+J` | Cycle secondary subtitle track | | `Ctrl+Alt+P` | Open playlist browser for current directory + queue | -| `ArrowRight` | Seek forward 5 seconds | -| `ArrowLeft` | Seek backward 5 seconds | -| `ArrowUp` | Seek forward 60 seconds | -| `ArrowDown` | Seek backward 60 seconds | -| `Shift+H` | Jump to previous subtitle | -| `Shift+L` | Jump to next subtitle | -| `Shift+[` | Shift subtitle delay to previous subtitle cue | -| `Shift+]` | Shift subtitle delay to next subtitle cue | -| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | -| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | -| `Q` | Quit mpv | -| `Ctrl+W` | Quit mpv | -| `Right-click` | Toggle pause (outside subtitle area) | -| `Right-click + drag` | Reposition subtitles (on subtitle area) | -| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | +| `ArrowRight` | Seek forward 5 seconds | +| `ArrowLeft` | Seek backward 5 seconds | +| `ArrowUp` | Seek forward 60 seconds | +| `ArrowDown` | Seek backward 60 seconds | +| `Shift+H` | Jump to previous subtitle | +| `Shift+L` | Jump to next subtitle | +| `Shift+[` | Shift subtitle delay to previous subtitle cue | +| `Shift+]` | Shift subtitle delay to next subtitle cue | +| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | +| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | +| `Q` | Quit mpv | +| `Ctrl+W` | Quit mpv | +| `Right-click` | Toggle pause (outside subtitle area) | +| `Right-click + drag` | Reposition subtitles (on subtitle area) | +| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. @@ -63,16 +63,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle ## Subtitle & Feature Shortcuts -| Shortcut | Action | Config key | -| ------------------ | -------------------------------------------------------- | ------------------------------ | -| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | -| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | -| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` | -| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | -| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | -| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | -| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | -| `` ` `` | Toggle stats overlay | `stats.toggleKey` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ----------------------------------- | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` | +| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | +| `` ` `` | Toggle stats overlay | `stats.toggleKey` | The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. @@ -115,7 +116,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 { diff --git a/docs-site/usage.md b/docs-site/usage.md index 0d446d4a..8dccb06b 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -100,6 +100,8 @@ 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 app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) @@ -124,6 +126,9 @@ 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 ``` @@ -166,6 +171,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 `: generates a Yomitan-importable character dictionary ZIP from a file/directory target. +- Use `subminer dictionary --candidates ` and `subminer dictionary --select ` 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`). diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 4a912e8d..4247aa74 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -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', () => { diff --git a/launcher/commands/dictionary-command.ts b/launcher/commands/dictionary-command.ts index 02b0785a..210ced30 100644 --- a/launcher/commands/dictionary-command.ts +++ b/launcher/commands/dictionary-command.ts @@ -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); } diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index e3359023..2b703e23 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -44,6 +44,8 @@ function createContext(): LauncherCommandContext { jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, doctor: false, doctorRefreshKnownWords: false, diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 71cbddc9..1cf6f801 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -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, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 23e36eeb..27e24c18 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -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, @@ -214,6 +224,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,6 +237,12 @@ 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; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index eb6de93f..eb0e06dc 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -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; @@ -136,6 +139,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; @@ -207,13 +213,18 @@ export function parseCliPrograms( commandProgram .command('dictionary') .alias('dict') - .description('Generate character dictionary ZIP from a file or directory target') - .argument('', '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 ', 'Pin an AniList media ID for the target series') .option('--log-level ', 'Log level') - .action((target: string, options: Record) => { + .action((target: string | undefined, options: Record) => { dictionaryTriggered = true; - dictionaryTarget = target; + dictionaryTarget = target ?? null; dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + dictionaryCandidates = options.candidates === true; + dictionarySelect = typeof options.select === 'string'; + dictionaryAnilistId = typeof options.select === 'string' ? options.select : null; }); commandProgram @@ -338,6 +349,9 @@ export function parseCliPrograms( dictionaryTriggered, dictionaryTarget, dictionaryLogLevel, + dictionaryCandidates, + dictionarySelect, + dictionaryAnilistId, statsTriggered, statsBackground, statsStop, diff --git a/launcher/main.test.ts b/launcher/main.test.ts index cf7b1f19..5fb01224 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -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`, ); }); }); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 1486e6a6..7d28f4c9 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -415,6 +415,8 @@ function makeArgs(overrides: Partial = {}): Args { jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, doctor: false, doctorRefreshKnownWords: false, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index ca133949..4bb8f3f9 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -99,6 +99,17 @@ 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 maps stats command and log-level override', () => { const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {}); diff --git a/launcher/types.ts b/launcher/types.ts index 64edbe27..588e12d7 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -121,6 +121,9 @@ export interface Args { jellyfinPlay: boolean; jellyfinDiscovery: boolean; dictionary: boolean; + dictionaryCandidates: boolean; + dictionarySelect: boolean; + dictionaryAnilistId?: number; stats: boolean; statsBackground?: boolean; statsStop?: boolean; diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 5047f5ca..7a67b7c5 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -212,6 +212,18 @@ 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); diff --git a/src/cli/args.ts b/src/cli/args.ts index 2df3e301..473a830e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -28,6 +28,7 @@ export interface CliArgs { toggleSubtitleSidebar: boolean; openRuntimeOptions: boolean; openSessionHelp: boolean; + openCharacterDictionary: boolean; openControllerSelect: boolean; openControllerDebug: boolean; openJimaku: boolean; @@ -46,6 +47,9 @@ export interface CliArgs { anilistSetup: boolean; anilistRetryQueue: boolean; dictionary: boolean; + dictionaryCandidates: boolean; + dictionarySelect: boolean; + dictionaryAnilistId?: number; dictionaryTarget?: string; stats: boolean; statsBackground?: boolean; @@ -122,6 +126,7 @@ export function parseArgs(argv: string[]): CliArgs { toggleSubtitleSidebar: false, openRuntimeOptions: false, openSessionHelp: false, + openCharacterDictionary: false, openControllerSelect: false, openControllerDebug: false, openJimaku: false, @@ -136,6 +141,8 @@ export function parseArgs(argv: string[]): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, statsBackground: false, statsStop: false, @@ -232,6 +239,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 +278,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') { @@ -460,6 +476,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || + args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -477,6 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.anilistSetup || args.anilistRetryQueue || args.dictionary || + args.dictionaryCandidates || + args.dictionarySelect || args.stats || args.jellyfin || args.jellyfinLogin || @@ -527,6 +546,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.toggleSubtitleSidebar && !args.openRuntimeOptions && !args.openSessionHelp && + !args.openCharacterDictionary && !args.openControllerSelect && !args.openControllerDebug && !args.openJimaku && @@ -544,6 +564,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.anilistSetup && !args.anilistRetryQueue && !args.dictionary && + !args.dictionaryCandidates && + !args.dictionarySelect && !args.stats && !args.jellyfin && !args.jellyfinLogin && @@ -585,6 +607,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || + args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -598,6 +621,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 || @@ -638,6 +663,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.toggleSubtitleSidebar && !args.openRuntimeOptions && !args.openSessionHelp && + !args.openCharacterDictionary && !args.openControllerSelect && !args.openControllerDebug && !args.openJimaku && @@ -655,6 +681,8 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.anilistSetup && !args.anilistRetryQueue && !args.dictionary && + !args.dictionaryCandidates && + !args.dictionarySelect && !args.stats && !args.jellyfin && !args.jellyfinLogin && @@ -696,6 +724,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.markAudioCard || args.openRuntimeOptions || args.openSessionHelp || + args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || diff --git a/src/cli/help.ts b/src/cli/help.ts index ad31ff7e..203627f5 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -38,6 +38,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 +48,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} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 85f59272..aa96a5be 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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); diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 30507bcf..b590cbd7 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -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', diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 6d356b82..79985f7a 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'mineSentenceMultiple', 'toggleSecondarySub', 'markAudioCard', + 'openCharacterDictionary', 'openRuntimeOptions', 'openJimaku', ] as const; diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index 37c5c5af..7d741a6b 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -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; diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 9d704e05..2a2238c4 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -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; 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. diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 545d12e9..5df865c6 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -37,6 +37,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -48,6 +49,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 2bfed141..2e3e4268 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -34,6 +34,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { refreshKnownWords: false, openRuntimeOptions: false, openSessionHelp: false, + openCharacterDictionary: false, openControllerSelect: false, openControllerDebug: false, openJimaku: false, @@ -50,6 +51,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, @@ -199,6 +203,19 @@ function createDeps(overrides: Partial = {}) { 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'); }, @@ -624,6 +641,77 @@ 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 dispatch runJellyfinCommand for non-Jellyfin commands', () => { const nonJellyfinArgs: Array> = [ { start: true }, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 85ba0168..6c2974b6 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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) => void; getMpvSocketPath: () => string; @@ -64,6 +85,13 @@ export interface CliCommandServiceDeps { mediaTitle: string; entryCount: number; }>; + getCharacterDictionarySelection: ( + targetPath?: string, + ) => Promise; + setCharacterDictionarySelection: (request: { + targetPath?: string; + mediaId: number; + }) => Promise; runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: (request: { @@ -162,6 +190,11 @@ export interface CliCommandDepsRuntimeOptions { mediaTitle: string; entryCount: number; }>; + getSelection: (targetPath?: string) => Promise; + setSelection: (request: { + targetPath?: string; + mediaId: number; + }) => Promise; }; jellyfin: { openSetup: () => void; @@ -237,6 +270,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 +302,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', @@ -411,6 +454,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 +595,71 @@ 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 .'); + if (shouldStopAfterRun) deps.stopApp(); + return; + } + deps + .setCharacterDictionarySelection({ + targetPath: args.dictionaryTarget, + mediaId: args.dictionaryAnilistId, + }) + .then((result) => { + 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) { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 049cadec..3d9be47d 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -1023,3 +1023,58 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy await saveHandler!({}, { preferredGamepadId: 12 }); }, /Invalid controller preference payload/); }); + +test('registerIpcHandlers exposes character dictionary selection handlers', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: number[] = []; + + registerIpcHandlers( + createRegisterIpcDeps({ + getCharacterDictionarySelection: async () => ({ + seriesKey: 're-zero-starting-life-in-another-world-2016', + guessTitle: 'Re ZERO, Starting Life in Another World', + current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 }, + override: null, + candidates: [ + { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }, + ], + }), + setCharacterDictionarySelection: async (mediaId) => { + calls.push(mediaId); + return { + ok: true, + seriesKey: 're-zero-starting-life-in-another-world-2016', + selected: { + id: mediaId, + title: 'Re:ZERO -Starting Life in Another World-', + episodes: 25, + }, + staleMediaIds: [10607], + }; + }, + }), + registrar, + ); + + const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection); + const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection); + + assert.deepEqual(await getHandler!({}), { + seriesKey: 're-zero-starting-life-in-another-world-2016', + guessTitle: 'Re ZERO, Starting Life in Another World', + current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 }, + override: null, + candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }], + }); + assert.deepEqual(await setHandler!({}, 0), { + ok: false, + message: 'Invalid AniList media ID.', + }); + assert.deepEqual(await setHandler!({}, 21355), { + ok: true, + seriesKey: 're-zero-starting-life-in-another-world-2016', + selected: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }, + staleMediaIds: [10607], + }); + assert.deepEqual(calls, [21355]); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 26f443ca..690bda20 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -90,6 +90,8 @@ export interface IpcServiceDeps { openAnilistSetup: () => void; getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + getCharacterDictionarySelection?: () => Promise; + setCharacterDictionarySelection?: (mediaId: number) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -211,6 +213,8 @@ export interface IpcDepsRuntimeOptions { openAnilistSetup: () => void; getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + getCharacterDictionarySelection?: () => Promise; + setCharacterDictionarySelection?: (mediaId: number) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -284,6 +288,23 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService openAnilistSetup: options.openAnilistSetup, getAnilistQueueStatus: options.getAnilistQueueStatus, retryAnilistQueueNow: options.retryAnilistQueueNow, + getCharacterDictionarySelection: + options.getCharacterDictionarySelection ?? + (async () => ({ + seriesKey: '', + guessTitle: null, + current: null, + override: null, + candidates: [], + })), + setCharacterDictionarySelection: + options.setCharacterDictionarySelection ?? + (async () => ({ + ok: false, + seriesKey: '', + selected: { id: 0, title: '', episodes: null }, + staleMediaIds: [], + })), appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, @@ -570,6 +591,31 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return await deps.retryAnilistQueueNow(); }); + ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => { + return await (deps.getCharacterDictionarySelection?.() ?? + Promise.resolve({ + seriesKey: '', + guessTitle: null, + current: null, + override: null, + candidates: [], + })); + }); + + ipc.handle( + IPC_CHANNELS.request.setCharacterDictionarySelection, + async (_event, mediaId: unknown) => { + if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) { + return { ok: false, message: 'Invalid AniList media ID.' }; + } + return await (deps.setCharacterDictionarySelection?.(mediaId as number) ?? + Promise.resolve({ + ok: false, + message: 'Character dictionary selection unavailable.', + })); + }, + ); + ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => { return deps.appendClipboardVideoToQueue(); }); diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index 2c168c06..fca5d0dc 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -25,6 +25,7 @@ function makeShortcuts(overrides: Partial = {}): Configured multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -45,6 +46,9 @@ function createDeps(overrides: Partial = {}) { openRuntimeOptions: () => { calls.push('openRuntimeOptions'); }, + openCharacterDictionary: () => { + calls.push('openCharacterDictionary'); + }, openJimaku: () => { calls.push('openJimaku'); }, @@ -154,6 +158,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions', }, { openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openCharacterDictionary: () => handled.push('openCharacterDictionary'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -186,6 +191,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re (_input, accelerator) => accelerator === 'Ctrl+M', { openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openCharacterDictionary: () => handled.push('openCharacterDictionary'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -205,6 +211,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re (_input, accelerator) => accelerator === 'Ctrl+N', { openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openCharacterDictionary: () => handled.push('openCharacterDictionary'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -241,6 +248,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s }, { openRuntimeOptions: () => {}, + openCharacterDictionary: () => {}, openJimaku: () => {}, markAudioCard: () => {}, copySubtitleMultiple: () => {}, @@ -276,6 +284,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', }, { openRuntimeOptions: () => {}, + openCharacterDictionary: () => {}, openJimaku: () => {}, markAudioCard: () => {}, copySubtitleMultiple: () => {}, @@ -303,6 +312,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', () openRuntimeOptions: () => { called = true; }, + openCharacterDictionary: () => { + called = true; + }, openJimaku: () => { called = true; }, @@ -385,6 +397,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured', mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -411,6 +424,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index b9517eb3..94556fed 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -6,6 +6,7 @@ const logger = createLogger('main:overlay-shortcut-handler'); export interface OverlayShortcutFallbackHandlers { openRuntimeOptions: () => void; + openCharacterDictionary: () => void; openJimaku: () => void; markAudioCard: () => void; copySubtitleMultiple: (timeoutMs: number) => void; @@ -21,6 +22,7 @@ export interface OverlayShortcutFallbackHandlers { export interface OverlayShortcutRuntimeDeps { showMpvOsd: (text: string) => void; openRuntimeOptions: () => void; + openCharacterDictionary: () => void; openJimaku: () => void; markAudioCard: () => Promise; copySubtitleMultiple: (timeoutMs: number) => void; @@ -95,6 +97,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim openRuntimeOptions: () => { deps.openRuntimeOptions(); }, + openCharacterDictionary: () => { + deps.openCharacterDictionary(); + }, openJimaku: () => { deps.openJimaku(); }, @@ -102,6 +107,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim const fallbackHandlers: OverlayShortcutFallbackHandlers = { openRuntimeOptions: overlayHandlers.openRuntimeOptions, + openCharacterDictionary: overlayHandlers.openCharacterDictionary, openJimaku: overlayHandlers.openJimaku, markAudioCard: overlayHandlers.markAudioCard, copySubtitleMultiple: overlayHandlers.copySubtitleMultiple, @@ -134,6 +140,12 @@ export function runOverlayShortcutLocalFallback( handlers.openRuntimeOptions(); }, }, + { + accelerator: shortcuts.openCharacterDictionary, + run: () => { + handlers.openCharacterDictionary(); + }, + }, { accelerator: shortcuts.openJimaku, run: () => { diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 72d30e7e..87598b98 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -20,6 +20,7 @@ function createShortcuts(overrides: Partial = {}): Configur multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -42,6 +43,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured' mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -61,6 +63,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent' mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -82,6 +85,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index cfd8375a..5a873896 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -10,6 +10,7 @@ export interface OverlayShortcutHandlers { mineSentenceMultiple: (timeoutMs: number) => void; toggleSecondarySub: () => void; markAudioCard: () => void; + openCharacterDictionary: () => void; openRuntimeOptions: () => void; openJimaku: () => void; } @@ -31,6 +32,7 @@ const OVERLAY_SHORTCUT_KEYS: Array Promise; openRuntimeOptionsPalette: () => void; openSessionHelp: () => void; + openCharacterDictionary: () => void; openControllerSelect: () => void; openControllerDebug: () => void; openJimaku: () => void; @@ -85,6 +86,9 @@ export async function dispatchSessionAction( case 'openSessionHelp': deps.openSessionHelp(); return; + case 'openCharacterDictionary': + deps.openCharacterDictionary(); + return; case 'openControllerSelect': deps.openControllerSelect(); return; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index cceea9b1..5cf95501 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -18,6 +18,7 @@ function createShortcuts(overrides: Partial = {}): Configur multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index d3956407..855e0e75 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -43,6 +43,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{ { key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' }, { key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' }, { key: 'markAudioCard', actionId: 'markAudioCard' }, + { key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openJimaku', actionId: 'openJimaku' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' }, diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 188b6c94..4edf10ff 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -37,6 +37,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -48,6 +49,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, diff --git a/src/core/utils/shortcut-config.test.ts b/src/core/utils/shortcut-config.test.ts index 9ac47a7e..f3f3119d 100644 --- a/src/core/utils/shortcut-config.test.ts +++ b/src/core/utils/shortcut-config.test.ts @@ -66,6 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { shortcuts: { mineSentence: 'KeyQ', openRuntimeOptions: 'Digit9', + openCharacterDictionary: 'Ctrl+Shift+KeyA', }, }; @@ -73,4 +74,5 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { assert.equal(resolved.mineSentence, 'Q'); assert.equal(resolved.openRuntimeOptions, '9'); + assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A'); }); diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index 25807dc6..0c9e3c01 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -12,6 +12,7 @@ export interface ConfiguredShortcuts { multiCopyTimeoutMs: number; toggleSecondarySub: string | null | undefined; markAudioCard: string | null | undefined; + openCharacterDictionary: string | null | undefined; openRuntimeOptions: string | null | undefined; openJimaku: string | null | undefined; openSessionHelp: string | null | undefined; @@ -76,6 +77,9 @@ export function resolveConfiguredShortcuts( ? null : (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard), ), + openCharacterDictionary: normalizeShortcut( + config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary, + ), openRuntimeOptions: normalizeShortcut( config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions, ), diff --git a/src/main.ts b/src/main.ts index dc45d5c3..bdbe427b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -458,6 +458,7 @@ import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open'; import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; +import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; @@ -1492,6 +1493,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, + openCharacterDictionary: () => { + openCharacterDictionaryOverlay(); + }, openJimaku: () => { openJimakuOverlay(); }, @@ -2290,6 +2294,14 @@ function openSessionHelpOverlay(): void { ); } +function openCharacterDictionaryOverlay(): void { + openOverlayHostedModalWithOsd( + openCharacterDictionaryModalRuntime, + 'Character dictionary overlay unavailable.', + 'Failed to open character dictionary overlay.', + ); +} + function openControllerSelectOverlay(): void { openOverlayHostedModalWithOsd( openControllerSelectModalRuntime, @@ -4622,6 +4634,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openJimaku: () => openJimakuOverlay(), openSessionHelp: () => openSessionHelpOverlay(), + openCharacterDictionary: () => openCharacterDictionaryOverlay(), openControllerSelect: () => openControllerSelectOverlay(), openControllerDebug: () => openControllerDebugOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), @@ -4842,6 +4855,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ openAnilistSetup: () => openAnilistSetupWindow(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + getCharacterDictionarySelection: () => + characterDictionaryRuntime.getManualSelectionSnapshot(), + setCharacterDictionarySelection: async (mediaId: number) => { + const result = await characterDictionaryRuntime.setManualSelection({ mediaId }); + resetAnilistMediaGuessState(); + await characterDictionaryAutoSyncRuntime.runSyncNow(); + return result; + }, appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), ...playlistBrowserMainDeps, getImmersionTracker: () => appState.immersionTracker, @@ -4923,6 +4944,14 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ } return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); }, + getCharacterDictionarySelection: async (targetPath?: string) => + characterDictionaryRuntime.getManualSelectionSnapshot(targetPath), + setCharacterDictionarySelection: async (request) => { + const result = await characterDictionaryRuntime.setManualSelection(request); + resetAnilistMediaGuessState(); + await characterDictionaryAutoSyncRuntime.runSyncNow(); + return result; + }, runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => runStatsCliCommand(argsFromCommand, source), diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 3480500d..3d663ca3 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -25,12 +25,21 @@ import { } from './character-dictionary-runtime/constants'; import { downloadCharacterImage, + fetchAniListMediaCandidateById, fetchCharactersForMedia, resolveAniListMediaIdFromGuess, + searchAniListMediaCandidates, } from './character-dictionary-runtime/fetch'; +import { + buildCharacterDictionarySeriesKey, + createCharacterDictionaryManualSelectionStore, +} from './character-dictionary-runtime/manual-selection'; import type { + AniListMediaCandidate, CharacterDictionaryBuildResult, CharacterDictionaryGenerateOptions, + CharacterDictionaryManualSelectionResult, + CharacterDictionaryManualSelectionSnapshot, CharacterDictionaryRuntimeDeps, CharacterDictionarySnapshotImage, CharacterDictionarySnapshotProgress, @@ -136,6 +145,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar progress?: CharacterDictionarySnapshotProgressCallbacks, ) => Promise; buildMergedDictionary: (mediaIds: number[]) => Promise; + getManualSelectionSnapshot: ( + targetPath?: string, + ) => Promise; + setManualSelection: (request: { + targetPath?: string; + mediaId: number; + }) => Promise; generateForCurrentMedia: ( targetPath?: string, options?: CharacterDictionaryGenerateOptions, @@ -144,26 +160,54 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar const outputDir = path.join(deps.userDataPath, 'character-dictionaries'); const sleepMs = deps.sleep ?? sleep; const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false); + const manualSelectionStore = createCharacterDictionaryManualSelectionStore({ + userDataPath: deps.userDataPath, + }); + + const createAniListRequestSlot = (): (() => Promise) => { + let hasAniListRequest = false; + return async (): Promise => { + if (!hasAniListRequest) { + hasAniListRequest = true; + return; + } + await sleepMs(ANILIST_REQUEST_DELAY_MS); + }; + }; + + const resolveGuessInput = (targetPath?: string): { mediaPath: string | null; mediaTitle: string | null } => { + const dictionaryTarget = targetPath?.trim() || ''; + return dictionaryTarget.length > 0 + ? resolveDictionaryGuessInputs(dictionaryTarget) + : { + mediaPath: deps.getCurrentMediaPath(), + mediaTitle: deps.getCurrentMediaTitle(), + }; + }; + + const guessCurrentMedia = async (targetPath?: string) => { + const guessInput = resolveGuessInput(targetPath); + const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath); + const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, guessInput.mediaTitle); + if (!guessed || !guessed.title.trim()) { + throw new Error('Unable to resolve current anime from media path/title.'); + } + return { + guessed, + seriesKey: buildCharacterDictionarySeriesKey({ + mediaPath: mediaPathForGuess, + mediaTitle: guessInput.mediaTitle, + guess: guessed, + }), + }; + }; const resolveCurrentMedia = async ( targetPath?: string, beforeRequest?: () => Promise, ): Promise => { deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation'); - const dictionaryTarget = targetPath?.trim() || ''; - const guessInput = - dictionaryTarget.length > 0 - ? resolveDictionaryGuessInputs(dictionaryTarget) - : { - mediaPath: deps.getCurrentMediaPath(), - mediaTitle: deps.getCurrentMediaTitle(), - }; - const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath); - const mediaTitle = guessInput.mediaTitle; - const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle); - if (!guessed || !guessed.title.trim()) { - throw new Error('Unable to resolve current anime from media path/title.'); - } + const { guessed, seriesKey } = await guessCurrentMedia(targetPath); deps.logInfo?.( `[dictionary] current anime guess: ${guessed.title.trim()}${ typeof guessed.episode === 'number' && guessed.episode > 0 @@ -171,6 +215,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar : '' }`, ); + const override = await manualSelectionStore.getOverride(seriesKey); + if (override) { + deps.logInfo?.( + `[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`, + ); + return { + id: override.mediaId, + title: override.mediaTitle, + staleMediaIds: override.staleMediaIds, + }; + } const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest); deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`); return resolved; @@ -269,13 +324,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar `[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`, ); - return { - mediaId: snapshot.mediaId, - mediaTitle: snapshot.mediaTitle, - entryCount: snapshot.entryCount, - fromCache: false, - updatedAt: snapshot.updatedAt, - }; + return { + mediaId: snapshot.mediaId, + mediaTitle: snapshot.mediaTitle, + entryCount: snapshot.entryCount, + fromCache: false, + updatedAt: snapshot.updatedAt, + }; }; return { @@ -283,25 +338,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar targetPath?: string, progress?: CharacterDictionarySnapshotProgressCallbacks, ) => { - let hasAniListRequest = false; - const waitForAniListRequestSlot = async (): Promise => { - if (!hasAniListRequest) { - hasAniListRequest = true; - return; - } - await sleepMs(ANILIST_REQUEST_DELAY_MS); - }; + const waitForAniListRequestSlot = createAniListRequestSlot(); const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); progress?.onChecking?.({ mediaId: resolvedMedia.id, mediaTitle: resolvedMedia.title, }); - return getOrCreateSnapshot( + const snapshot = await getOrCreateSnapshot( resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot, progress, ); + return { + ...snapshot, + staleMediaIds: resolvedMedia.staleMediaIds, + }; }, buildMergedDictionary: async (mediaIds: number[]) => { const normalizedMediaIds = normalizeMergedMediaIds(mediaIds); @@ -341,18 +393,58 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar entryCount, }; }, + getManualSelectionSnapshot: async (targetPath?: string) => { + const waitForAniListRequestSlot = createAniListRequestSlot(); + const { guessed, seriesKey } = await guessCurrentMedia(targetPath); + const [candidates, override] = await Promise.all([ + searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot), + manualSelectionStore.getOverride(seriesKey), + ]); + const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot) + .then( + (entry): AniListMediaCandidate => ({ + id: entry.id, + title: entry.title, + episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null, + }), + ) + .catch(() => null); + return { + seriesKey, + guessTitle: guessed.title, + current, + override: override + ? { id: override.mediaId, title: override.mediaTitle, episodes: null } + : null, + candidates, + }; + }, + setManualSelection: async ({ targetPath, mediaId }) => { + const waitForAniListRequestSlot = createAniListRequestSlot(); + const { guessed, seriesKey } = await guessCurrentMedia(targetPath); + const [selected, current] = await Promise.all([ + fetchAniListMediaCandidateById(mediaId, waitForAniListRequestSlot), + resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot).catch(() => null), + ]); + const staleMediaIds = current && current.id !== selected.id ? [current.id] : []; + await manualSelectionStore.setOverride({ + seriesKey, + mediaId: selected.id, + mediaTitle: selected.title, + staleMediaIds, + }); + return { + ok: true, + seriesKey, + selected, + staleMediaIds, + }; + }, generateForCurrentMedia: async ( targetPath?: string, _options?: CharacterDictionaryGenerateOptions, ) => { - let hasAniListRequest = false; - const waitForAniListRequestSlot = async (): Promise => { - if (!hasAniListRequest) { - hasAniListRequest = true; - return; - } - await sleepMs(ANILIST_REQUEST_DELAY_MS); - }; + const waitForAniListRequestSlot = createAniListRequestSlot(); const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); const snapshot = await getOrCreateSnapshot( resolvedMedia.id, diff --git a/src/main/character-dictionary-runtime/fetch.ts b/src/main/character-dictionary-runtime/fetch.ts index 61ba2454..0c84b05b 100644 --- a/src/main/character-dictionary-runtime/fetch.ts +++ b/src/main/character-dictionary-runtime/fetch.ts @@ -1,6 +1,7 @@ import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; import { ANILIST_GRAPHQL_URL } from './constants'; import type { + AniListMediaCandidate, CharacterDictionaryRole, CharacterRecord, ResolvedAniListMedia, @@ -123,6 +124,29 @@ function pickAniListSearchResult( }; } +function toAniListMediaCandidate( + entry: { + id: number; + episodes?: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + }, + fallbackTitle: string, +): AniListMediaCandidate { + return { + id: entry.id, + title: + entry.title?.english?.trim() || + entry.title?.romaji?.trim() || + entry.title?.native?.trim() || + fallbackTitle, + episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null, + }; +} + async function fetchAniList( query: string, variables: Record, @@ -208,6 +232,69 @@ export async function resolveAniListMediaIdFromGuess( return resolved; } +export async function searchAniListMediaCandidates( + title: string, + beforeRequest?: () => Promise, +): Promise { + const data = await fetchAniList( + ` + query($search: String!) { + Page(perPage: 10) { + media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) { + id + episodes + title { + romaji + english + native + } + } + } + } + `, + { search: title }, + beforeRequest, + ); + return (data.Page?.media ?? []).map((entry) => toAniListMediaCandidate(entry, title)); +} + +export async function fetchAniListMediaCandidateById( + mediaId: number, + beforeRequest?: () => Promise, +): Promise { + const data = await fetchAniList<{ + Media?: { + id: number; + episodes?: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + } | null; + }>( + ` + query($id: Int!) { + Media(id: $id, type: ANIME) { + id + episodes + title { + romaji + english + native + } + } + } + `, + { id: mediaId }, + beforeRequest, + ); + if (!data.Media) { + throw new Error(`AniList media ${mediaId} not found.`); + } + return toAniListMediaCandidate(data.Media, `AniList ${mediaId}`); +} + export async function fetchCharactersForMedia( mediaId: number, beforeRequest?: () => Promise, diff --git a/src/main/character-dictionary-runtime/manual-selection.test.ts b/src/main/character-dictionary-runtime/manual-selection.test.ts new file mode 100644 index 00000000..e7901b40 --- /dev/null +++ b/src/main/character-dictionary-runtime/manual-selection.test.ts @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + buildCharacterDictionarySeriesKey, + createCharacterDictionaryManualSelectionStore, +} from './manual-selection'; + +const REZERO_EP1 = + '/anime/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'; +const REZERO_EP2 = + '/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv'; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-')); +} + +test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => { + const key = buildCharacterDictionarySeriesKey({ + mediaPath: REZERO_EP1, + mediaTitle: null, + guess: { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 1, + source: 'guessit', + }, + }); + + assert.equal(key, 're-zero-starting-life-in-another-world-2016'); +}); + +test('manual selection store persists overrides and matches later episodes in the same series', async () => { + const userDataPath = makeTempDir(); + const store = createCharacterDictionaryManualSelectionStore({ userDataPath }); + const firstKey = buildCharacterDictionarySeriesKey({ + mediaPath: REZERO_EP1, + mediaTitle: null, + guess: { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 1, + source: 'guessit', + }, + }); + await store.setOverride({ + seriesKey: firstKey, + mediaId: 21355, + mediaTitle: 'Re:ZERO -Starting Life in Another World-', + staleMediaIds: [10607], + }); + + const reloaded = createCharacterDictionaryManualSelectionStore({ userDataPath }); + const secondKey = buildCharacterDictionarySeriesKey({ + mediaPath: REZERO_EP2, + mediaTitle: null, + guess: { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 2, + source: 'guessit', + }, + }); + + assert.equal(secondKey, firstKey); + assert.deepEqual(await reloaded.getOverride(secondKey), { + seriesKey: firstKey, + mediaId: 21355, + mediaTitle: 'Re:ZERO -Starting Life in Another World-', + staleMediaIds: [10607], + }); +}); diff --git a/src/main/character-dictionary-runtime/manual-selection.ts b/src/main/character-dictionary-runtime/manual-selection.ts new file mode 100644 index 00000000..63345f6d --- /dev/null +++ b/src/main/character-dictionary-runtime/manual-selection.ts @@ -0,0 +1,122 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; +import { ensureDir } from '../../shared/fs-utils'; + +export type CharacterDictionaryManualSelection = { + seriesKey: string; + mediaId: number; + mediaTitle: string; + staleMediaIds: number[]; +}; + +type ManualSelectionStoreFile = { + overrides?: CharacterDictionaryManualSelection[]; +}; + +function normalizeManualMediaId(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const mediaId = Math.floor(value); + return mediaId > 0 ? mediaId : null; +} + +function normalizeSeriesKeyPart(value: string): string { + return value + .normalize('NFKD') + .replace(/[':]/g, '') + .replace(/&/g, ' and ') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-') + .toLowerCase(); +} + +function dedupeNumbers(values: number[]): number[] { + const seen = new Set(); + const result: number[] = []; + for (const value of values) { + const normalized = normalizeManualMediaId(value); + if (normalized === null || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function normalizeOverride(value: unknown): CharacterDictionaryManualSelection | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Partial; + const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : ''; + const mediaId = normalizeManualMediaId(raw.mediaId); + const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : ''; + if (!seriesKey || mediaId === null || !mediaTitle) return null; + return { + seriesKey, + mediaId, + mediaTitle, + staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []), + }; +} + +function readOverrides(filePath: string): CharacterDictionaryManualSelection[] { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ManualSelectionStoreFile; + if (!Array.isArray(parsed.overrides)) return []; + const byKey = new Map(); + for (const value of parsed.overrides) { + const normalized = normalizeOverride(value); + if (normalized) byKey.set(normalized.seriesKey, normalized); + } + return [...byKey.values()]; + } catch { + return []; + } +} + +function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSelection[]): void { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8'); +} + +export function buildCharacterDictionarySeriesKey(input: { + mediaPath: string | null; + mediaTitle: string | null; + guess: AnilistMediaGuess | null; +}): string { + const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || ''; + const sourceTitle = + guessedTitle || + (input.mediaTitle && input.mediaTitle.trim()) || + (input.mediaPath && path.basename(input.mediaPath).replace(/\.[^.]+$/, '')) || + 'unknown'; + const withoutEpisode = sourceTitle + .replace(/\bS\d{1,2}E\d{1,3}\b/gi, ' ') + .replace(/\bepisode\s+\d+\b/gi, ' ') + .trim(); + const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown'; + return input.guess?.year ? `${base}-${input.guess.year}` : base; +} + +export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) { + const filePath = path.join( + deps.userDataPath, + 'character-dictionaries', + 'anilist-overrides.json', + ); + + return { + getOverride: async (seriesKey: string): Promise => { + return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null; + }, + setOverride: async (selection: CharacterDictionaryManualSelection): Promise => { + const normalized = normalizeOverride(selection); + if (!normalized) { + throw new Error('Invalid character dictionary manual selection.'); + } + const remaining = readOverrides(filePath).filter( + (entry) => entry.seriesKey !== normalized.seriesKey, + ); + writeOverrides(filePath, [...remaining, normalized]); + }, + }; +} diff --git a/src/main/character-dictionary-runtime/types.ts b/src/main/character-dictionary-runtime/types.ts index 81b057d4..a12e20b2 100644 --- a/src/main/character-dictionary-runtime/types.ts +++ b/src/main/character-dictionary-runtime/types.ts @@ -93,6 +93,7 @@ export type CharacterDictionarySnapshotResult = { entryCount: number; fromCache: boolean; updatedAt: number; + staleMediaIds?: number[]; }; export type CharacterDictionarySnapshotProgress = { @@ -112,6 +113,27 @@ export type MergedCharacterDictionaryBuildResult = { entryCount: number; }; +export type AniListMediaCandidate = { + id: number; + title: string; + episodes: number | null; +}; + +export type CharacterDictionaryManualSelectionSnapshot = { + seriesKey: string; + guessTitle: string | null; + current: AniListMediaCandidate | null; + override: AniListMediaCandidate | null; + candidates: AniListMediaCandidate[]; +}; + +export type CharacterDictionaryManualSelectionResult = { + ok: boolean; + seriesKey: string; + selected: AniListMediaCandidate; + staleMediaIds: number[]; +}; + export interface CharacterDictionaryRuntimeDeps { userDataPath: string; getCurrentMediaPath: () => string | null; @@ -133,4 +155,5 @@ export interface CharacterDictionaryRuntimeDeps { export type ResolvedAniListMedia = { id: number; title: string; + staleMediaIds?: number[]; }; diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 4cec9dc0..ae579c30 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -37,6 +37,8 @@ export interface CliCommandRuntimeServiceContext { getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus']; retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow']; generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate']; + getCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['getSelection']; + setCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['setSelection']; openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; @@ -103,6 +105,8 @@ function createCliCommandDepsFromContext( }, dictionary: { generate: context.generateCharacterDictionary, + getSelection: context.getCharacterDictionarySelection, + setSelection: context.setCharacterDictionarySelection, }, jellyfin: { openSetup: context.openJellyfinSetup, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 0e9646f1..a2ff3f02 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -94,6 +94,8 @@ export interface MainIpcRuntimeServiceDepsParams { openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; + getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; @@ -169,6 +171,8 @@ export interface CliCommandRuntimeServiceDepsParams { }; dictionary: { generate: CliCommandDepsRuntimeOptions['dictionary']['generate']; + getSelection: CliCommandDepsRuntimeOptions['dictionary']['getSelection']; + setSelection: CliCommandDepsRuntimeOptions['dictionary']['setSelection']; }; jellyfin: { openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup']; @@ -258,6 +262,8 @@ export function createMainIpcRuntimeServiceDeps( openAnilistSetup: params.openAnilistSetup, getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, + getCharacterDictionarySelection: params.getCharacterDictionarySelection, + setCharacterDictionarySelection: params.setCharacterDictionarySelection, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, @@ -341,6 +347,8 @@ export function createCliCommandRuntimeServiceDeps( }, dictionary: { generate: params.dictionary.generate, + getSelection: params.dictionary.getSelection, + setSelection: params.dictionary.setSelection, }, jellyfin: { openSetup: params.jellyfin.openSetup, diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts index 4b4e3ae7..1e1d7d7a 100644 --- a/src/main/overlay-shortcuts-runtime.ts +++ b/src/main/overlay-shortcuts-runtime.ts @@ -19,6 +19,7 @@ export interface OverlayShortcutRuntimeServiceInput { isOverlayShortcutContextActive?: () => boolean; showMpvOsd: (text: string) => void; openRuntimeOptionsPalette: () => void; + openCharacterDictionary: () => void; openJimaku: () => void; markAudioCard: () => Promise; copySubtitleMultiple: (timeoutMs: number) => void; @@ -49,6 +50,9 @@ export function createOverlayShortcutsRuntimeService( openRuntimeOptions: () => { input.openRuntimeOptionsPalette(); }, + openCharacterDictionary: () => { + input.openCharacterDictionary(); + }, openJimaku: () => { input.openJimaku(); }, diff --git a/src/main/runtime/character-dictionary-auto-sync.test.ts b/src/main/runtime/character-dictionary-auto-sync.test.ts index f4b1d486..529eb0b1 100644 --- a/src/main/runtime/character-dictionary-auto-sync.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync.test.ts @@ -459,6 +459,69 @@ test('auto sync keeps revisited media retained when a new title is added afterwa assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']); }); +test('auto sync removes stale manual-selection media ids when applying corrected snapshot', async () => { + const userDataPath = makeTempDir(); + const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); + fs.mkdirSync(dictionariesDir, { recursive: true }); + fs.writeFileSync( + path.join(dictionariesDir, 'auto-sync-state.json'), + JSON.stringify( + { + activeMediaIds: [ + '10607 - Rerere no Tensai Bakabon', + '130298 - The Eminence in Shadow', + ], + mergedRevision: 'old', + mergedDictionaryTitle: 'SubMiner Character Dictionary', + }, + null, + 2, + ), + ); + const builtMediaIds: number[][] = []; + const runtime = createCharacterDictionaryAutoSyncRuntimeService({ + userDataPath, + getConfig: () => ({ + enabled: true, + maxLoaded: 5, + profileScope: 'all', + }), + getOrCreateCurrentSnapshot: async () => ({ + mediaId: 21355, + mediaTitle: 'Re:ZERO -Starting Life in Another World-', + entryCount: 120, + fromCache: false, + updatedAt: 99, + staleMediaIds: [10607], + }), + buildMergedDictionary: async (mediaIds) => { + builtMediaIds.push([...mediaIds]); + return { + zipPath: path.join(dictionariesDir, 'merged.zip'), + revision: `rev-${mediaIds.join('-')}`, + dictionaryTitle: 'SubMiner Character Dictionary', + entryCount: 200, + }; + }, + getYomitanDictionaryInfo: async () => [], + importYomitanDictionary: async () => true, + deleteYomitanDictionary: async () => true, + upsertYomitanDictionarySettings: async () => false, + now: () => 1, + }); + + await runtime.runSyncNow(); + + assert.deepEqual(builtMediaIds, [[21355, 130298]]); + const state = JSON.parse( + fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'), + ) as { activeMediaIds: string[] }; + assert.deepEqual(state.activeMediaIds, [ + '21355 - Re:ZERO -Starting Life in Another World-', + '130298 - The Eminence in Shadow', + ]); +}); + test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => { const userDataPath = makeTempDir(); const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); diff --git a/src/main/runtime/character-dictionary-auto-sync.ts b/src/main/runtime/character-dictionary-auto-sync.ts index d65e1d6a..08474893 100644 --- a/src/main/runtime/character-dictionary-auto-sync.ts +++ b/src/main/runtime/character-dictionary-auto-sync.ts @@ -271,12 +271,19 @@ export function createCharacterDictionaryAutoSyncRuntimeService( currentMediaId = snapshot.mediaId; currentMediaTitle = snapshot.mediaTitle; const state = readAutoSyncState(statePath); + const staleMediaIds = new Set( + (snapshot.staleMediaIds ?? []) + .map((mediaId) => normalizeMediaId(mediaId)) + .filter((mediaId): mediaId is number => mediaId !== null), + ); const nextActiveMediaIds = [ { mediaId: snapshot.mediaId, label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle), }, - ...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId), + ...state.activeMediaIds.filter( + (entry) => entry.mediaId !== snapshot.mediaId && !staleMediaIds.has(entry.mediaId), + ), ].slice(0, Math.max(1, Math.floor(config.maxLoaded))); const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId); deps.logInfo?.( diff --git a/src/main/runtime/character-dictionary-open.ts b/src/main/runtime/character-dictionary-open.ts new file mode 100644 index 00000000..6db72628 --- /dev/null +++ b/src/main/runtime/character-dictionary-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary'; +const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500; + +export async function openCharacterDictionaryModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: CHARACTER_DICTIONARY_MODAL, + timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS, + retryWarning: + 'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.characterDictionaryOpen, + modal: CHARACTER_DICTIONARY_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 0a161295..380489c7 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -36,6 +36,8 @@ export function createBuildCliCommandContextDepsHandler(deps: { getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; @@ -86,6 +88,8 @@ export function createBuildCliCommandContextDepsHandler(deps: { getAnilistQueueStatus: deps.getAnilistQueueStatus, retryAnilistQueueNow: deps.retryAnilistQueueNow, generateCharacterDictionary: deps.generateCharacterDictionary, + getCharacterDictionarySelection: deps.getCharacterDictionarySelection, + setCharacterDictionarySelection: deps.setCharacterDictionarySelection, runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 18fb7106..4ec2913b 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -48,6 +48,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; @@ -113,6 +115,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), generateCharacterDictionary: (targetPath?: string) => deps.generateCharacterDictionary(targetPath), + getCharacterDictionarySelection: deps.getCharacterDictionarySelection, + setCharacterDictionarySelection: deps.setCharacterDictionarySelection, runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index f4fd2f31..b924b3eb 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -41,6 +41,8 @@ export type CliCommandContextFactoryDeps = { getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandRuntimeServiceContext['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; @@ -98,6 +100,23 @@ export function createCliCommandContext( getAnilistQueueStatus: deps.getAnilistQueueStatus, retryAnilistQueueNow: deps.retryAnilistQueueNow, generateCharacterDictionary: deps.generateCharacterDictionary, + getCharacterDictionarySelection: + deps.getCharacterDictionarySelection ?? + (async () => ({ + seriesKey: '', + guessTitle: null, + current: null, + override: null, + candidates: [], + })), + setCharacterDictionarySelection: + deps.setCharacterDictionarySelection ?? + (async () => ({ + ok: false, + seriesKey: '', + selected: { id: 0, title: '', episodes: null }, + staleMediaIds: [], + })), runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 80ca0557..a56d0da7 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -51,6 +51,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -62,6 +63,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index e192fae7..d916540c 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -16,6 +16,7 @@ function createShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 5000, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index 995c0ce1..871bcc5f 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 5000, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts index 69c1126a..59c68cd1 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -16,6 +16,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call isOverlayShortcutContextActive: () => false, showMpvOsd: (text) => calls.push(`osd:${text}`), openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openCharacterDictionary: () => calls.push('character-dictionary'), openJimaku: () => calls.push('jimaku'), markAudioCard: async () => { calls.push('mark-audio'); @@ -47,6 +48,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call assert.equal(shortcutsRegistered, true); deps.showMpvOsd('x'); deps.openRuntimeOptionsPalette(); + deps.openCharacterDictionary(); deps.openJimaku(); await deps.markAudioCard(); deps.copySubtitleMultiple(5000); @@ -63,6 +65,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call 'registered:true', 'osd:x', 'runtime-options', + 'character-dictionary', 'jimaku', 'mark-audio', 'copy-multi:5000', diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts index b6eb9b16..ef227b7d 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -11,6 +11,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler( isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true, showMpvOsd: (text: string) => deps.showMpvOsd(text), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + openCharacterDictionary: () => deps.openCharacterDictionary(), openJimaku: () => deps.openJimaku(), markAudioCard: () => deps.markAudioCard(), copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), diff --git a/src/preload.ts b/src/preload.ts index b29be004..7ce9dda1 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -124,6 +124,9 @@ function createQueuedIpcListenerWithPayload( const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenCharacterDictionaryEvent = createQueuedIpcListener( + IPC_CHANNELS.event.characterDictionaryOpen, +); const onOpenControllerSelectEvent = createQueuedIpcListener( IPC_CHANNELS.event.controllerSelectOpen, ); @@ -340,6 +343,7 @@ const electronAPI: ElectronAPI = { onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, + onOpenCharacterDictionary: onOpenCharacterDictionaryEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, @@ -363,6 +367,10 @@ const electronAPI: ElectronAPI = { request: YoutubePickerResolveRequest, ): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request), + getCharacterDictionarySelection: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection), + setCharacterDictionarySelection: (mediaId: number) => + ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId), notifyOverlayModalClosed: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 3250ee44..322d84bd 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -365,6 +365,7 @@ function createKeyboardHandlerHarness() { const handlers = createKeyboardHandlers(ctx as never, { handleRuntimeOptionsKeydown: () => false, + handleCharacterDictionaryKeydown: () => false, handleSubsyncKeydown: () => false, handleKikuKeydown: () => false, handleJimakuKeydown: () => false, diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 33a617a4..dfef40c3 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -12,6 +12,7 @@ export function createKeyboardHandlers( ctx: RendererContext, options: { handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean; + handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; @@ -1004,6 +1005,10 @@ export function createKeyboardHandlers( options.handleRuntimeOptionsKeydown(e); return; } + if (ctx.state.characterDictionaryModalOpen) { + options.handleCharacterDictionaryKeydown(e); + return; + } if (ctx.state.subsyncModalOpen) { options.handleSubsyncKeydown(e); return; diff --git a/src/renderer/index.html b/src/renderer/index.html index 2221946a..fd06776a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -197,6 +197,20 @@ +