mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 04:19:27 -07:00
feat: add manual AniList selection for character dictionaries
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-291
|
||||
title: Add manual AniList selection for character dictionary resolution
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-04-25 21:29'
|
||||
updated_date: '2026-04-25 22:51'
|
||||
labels:
|
||||
- dictionary
|
||||
- anilist
|
||||
- cli
|
||||
- ui
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add CLI and in-app UI support for correcting character dictionary anime resolution when guessit or AniList search picks the wrong series. Manual selections must apply to the whole detected series, persist across episodes, and replace stale incorrect entries in the auto-sync merged character dictionary state. Do not add tray UI. Known regression case: `Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv` previously resolved to `10607 - Rerere no Tensai Bakabon`; it should be correctable to the chosen Re:ZERO AniList media and then reused for later files in that series.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 CLI can show AniList candidate matches for the current/target media and set a manual character-dictionary AniList override.
|
||||
- [x] #2 In-app UI can show the current character-dictionary match, candidate matches, and apply an override without adding tray controls.
|
||||
- [x] #3 Persisted overrides are keyed at series scope so all later episodes in the same series reuse the selected AniList media.
|
||||
- [x] #4 Applying an override clears stale guess state, replaces the old incorrect active media entry in auto-sync state, rebuilds/imports the merged character dictionary, and refreshes subtitle dictionary usage.
|
||||
- [x] #5 Regression tests cover the Re:ZERO filename, override reuse, stale active-media replacement, CLI handling, and IPC/UI contract behavior.
|
||||
- [x] #6 Docs are updated for manual character dictionary anime selection.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add a focused override/resolution layer for character dictionary media selection: derive a stable series key from filename/guessit data, persist manual AniList media overrides under user data, and expose AniList candidate search helpers.
|
||||
2. Update character dictionary snapshot resolution to check manual overrides before guessit-derived AniList search, and update auto-sync so applying an override removes stale incorrect media IDs and rebuilds/imports the merged dictionary.
|
||||
3. Extend CLI with commands to list candidates and set an override for current or target media.
|
||||
4. Extend existing in-app settings UI via IPC/preload contracts: show current match/candidates and let user apply an override. No tray controls.
|
||||
5. Use TDD: add failing regressions first for Re:ZERO parsing/override behavior, auto-sync replacement, CLI handling, IPC contract, and UI state; then implement.
|
||||
6. Update docs-site/manual docs for manual character dictionary anime selection, launcher usage, and the default `Ctrl+Alt+A` modal shortcut, then run focused tests and broader gates as time permits.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
5
changes/291-character-dictionary-selection.md
Normal file
5
changes/291-character-dictionary-selection.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -28,9 +28,9 @@ Character dictionary sync is disabled by default. To turn it on:
|
||||
"enabled": true,
|
||||
"accessToken": "your-token",
|
||||
"characterDictionary": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -47,33 +47,35 @@ If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external
|
||||
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
|
||||
|
||||
**Spacing and combination:**
|
||||
|
||||
- Full name with space: 須々木 心一
|
||||
- Combined form: 須々木心一
|
||||
- Family name alone: 須々木
|
||||
- Given name alone: 心一
|
||||
|
||||
**Middle-dot removal** (common in katakana foreign names):
|
||||
|
||||
- ア・リ・ス → アリス (combined), plus individual segments
|
||||
|
||||
**Honorific suffixes** — each base name is expanded with 15 common suffixes:
|
||||
|
||||
| Honorific | Reading |
|
||||
| --- | --- |
|
||||
| さん | さん |
|
||||
| 様 | さま |
|
||||
| 先生 | せんせい |
|
||||
| 先輩 | せんぱい |
|
||||
| 後輩 | こうはい |
|
||||
| 氏 | し |
|
||||
| 君 | くん |
|
||||
| くん | くん |
|
||||
| ちゃん | ちゃん |
|
||||
| たん | たん |
|
||||
| 坊 | ぼう |
|
||||
| 殿 | どの |
|
||||
| 博士 | はかせ |
|
||||
| 社長 | しゃちょう |
|
||||
| 部長 | ぶちょう |
|
||||
| Honorific | Reading |
|
||||
| --------- | ---------- |
|
||||
| さん | さん |
|
||||
| 様 | さま |
|
||||
| 先生 | せんせい |
|
||||
| 先輩 | せんぱい |
|
||||
| 後輩 | こうはい |
|
||||
| 氏 | し |
|
||||
| 君 | くん |
|
||||
| くん | くん |
|
||||
| ちゃん | ちゃん |
|
||||
| たん | たん |
|
||||
| 坊 | ぼう |
|
||||
| 殿 | どの |
|
||||
| 博士 | はかせ |
|
||||
| 社長 | しゃちょう |
|
||||
| 部長 | ぶちょう |
|
||||
|
||||
**Romanized names** — names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text.
|
||||
|
||||
@@ -92,10 +94,10 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||
| Option | Default | Description |
|
||||
| -------------------------------- | --------- | ---------------------------------- |
|
||||
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||
|
||||
## Dictionary Entries
|
||||
|
||||
@@ -117,10 +119,10 @@ The three collapsible sections can be configured to start open or closed:
|
||||
"collapsibleSections": {
|
||||
"description": false,
|
||||
"characterInformation": false,
|
||||
"voicedBy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"voicedBy": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -143,7 +145,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
|
||||
{
|
||||
"activeMediaIds": [170942, 163134, 154587],
|
||||
"mergedRevision": "a1b2c3d4e5f6",
|
||||
"mergedDictionaryTitle": "SubMiner Character Dictionary"
|
||||
"mergedDictionaryTitle": "SubMiner Character Dictionary",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -163,6 +165,29 @@ SubMiner.AppImage --dictionary
|
||||
|
||||
This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
|
||||
|
||||
## Correcting AniList Matches
|
||||
|
||||
SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
||||
|
||||
Use the in-app selector or CLI to pin the correct AniList media for the whole series:
|
||||
|
||||
```bash
|
||||
# List candidate AniList matches for a file
|
||||
subminer dictionary --candidates "/path/to/episode.mkv"
|
||||
|
||||
# Save the correct AniList media ID for that series
|
||||
subminer dictionary --select 21355 "/path/to/episode.mkv"
|
||||
|
||||
# Equivalent direct app flags
|
||||
SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv"
|
||||
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
|
||||
|
||||
# Open the in-app selector from the running app
|
||||
subminer app --open-character-dictionary
|
||||
```
|
||||
|
||||
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||
|
||||
## File Structure
|
||||
|
||||
All character dictionary data lives under `{userData}/character-dictionaries/`:
|
||||
@@ -174,6 +199,7 @@ character-dictionaries/
|
||||
anilist-163134.json
|
||||
merged.zip # Active merged dictionary (imported into Yomitan)
|
||||
auto-sync-state.json # Tracks active media IDs and revision
|
||||
anilist-overrides.json # Manual series-to-AniList overrides
|
||||
img/
|
||||
m170942-c12345.jpg # Character portrait
|
||||
m170942-va67890.jpg # Voice actor portrait
|
||||
@@ -194,16 +220,16 @@ merged.zip
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
|
||||
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
|
||||
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
|
||||
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
||||
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||
| Option | Default | Description |
|
||||
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
|
||||
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
|
||||
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
|
||||
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
|
||||
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
||||
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
@@ -211,14 +237,14 @@ SubMiner's character dictionary builder is inspired by the [Japanese Character N
|
||||
|
||||
The reference implementation covers similar ground — name variant generation, honorific expansion, structured Yomitan content, portrait embedding — and additionally supports VNDB as a data source for visual novel characters. Key differences:
|
||||
|
||||
| | SubMiner | Reference Implementation |
|
||||
| --- | --- | --- |
|
||||
| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
|
||||
| **Data sources** | AniList only | AniList + VNDB |
|
||||
| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
|
||||
| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export |
|
||||
| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) |
|
||||
| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh |
|
||||
| | SubMiner | Reference Implementation |
|
||||
| ---------------------- | -------------------------------------------- | ------------------------------------- |
|
||||
| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
|
||||
| **Data sources** | AniList only | AniList + VNDB |
|
||||
| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
|
||||
| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export |
|
||||
| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) |
|
||||
| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh |
|
||||
|
||||
If you work with visual novels or want a standalone dictionary generator independent of SubMiner, the reference implementation is worth checking out.
|
||||
|
||||
@@ -226,7 +252,7 @@ If you work with visual novels or want a standalone dictionary generator indepen
|
||||
|
||||
- **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters.
|
||||
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
|
||||
- **Wrong characters showing:** The merged dictionary includes your `maxLoaded` most recent titles. If you're seeing names from a previous show, they'll rotate out once you watch enough new titles to push it past the limit.
|
||||
- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`.
|
||||
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
|
||||
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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 <path>` | Generate character dictionary ZIP from file/dir target |
|
||||
| `subminer texthooker` | Launch texthooker-only mode |
|
||||
| `subminer app` | Pass arguments directly to SubMiner binary |
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
|
||||
| `subminer stats` | Start stats server and open immersion dashboard in browser |
|
||||
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
| `subminer mpv socket` | Print active socket path |
|
||||
| `subminer mpv idle` | Launch detached idle mpv instance |
|
||||
| `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target |
|
||||
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
|
||||
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
|
||||
| `subminer texthooker` | Launch texthooker-only mode |
|
||||
| `subminer app` | Pass arguments directly to SubMiner binary |
|
||||
|
||||
Use `subminer <subcommand> -h` for command-specific help.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| --------------------- | --------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||
| Flag | Description |
|
||||
| --------------------- | -------------------------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||
|
||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
|
||||
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||
|
||||
@@ -190,7 +190,43 @@ test('dictionary command forwards --dictionary and target path to app binary', (
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
|
||||
assert.deepEqual(forwarded, [['--start', '--dictionary', '--dictionary-target', '/tmp/anime']]);
|
||||
});
|
||||
|
||||
test('dictionary command forwards candidate and selection modes to app binary', () => {
|
||||
const candidatesContext = createContext();
|
||||
candidatesContext.args.dictionary = true;
|
||||
candidatesContext.args.dictionaryCandidates = true;
|
||||
candidatesContext.args.dictionaryTarget = '/tmp/anime.mkv';
|
||||
const selectContext = createContext();
|
||||
selectContext.args.dictionary = true;
|
||||
selectContext.args.dictionarySelect = true;
|
||||
selectContext.args.dictionaryAnilistId = 21355;
|
||||
selectContext.args.dictionaryTarget = '/tmp/anime.mkv';
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
runDictionaryCommand(candidatesContext, {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
});
|
||||
runDictionaryCommand(selectContext, {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(forwarded, [
|
||||
['--start', '--dictionary-candidates', '--dictionary-target', '/tmp/anime.mkv'],
|
||||
[
|
||||
'--start',
|
||||
'--dictionary-select',
|
||||
'--dictionary-anilist-id',
|
||||
'21355',
|
||||
'--dictionary-target',
|
||||
'/tmp/anime.mkv',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('dictionary command returns after app handoff starts', () => {
|
||||
|
||||
@@ -18,7 +18,20 @@ export function runDictionaryCommand(
|
||||
return false;
|
||||
}
|
||||
|
||||
const forwarded = ['--dictionary'];
|
||||
const forwarded = [
|
||||
'--start',
|
||||
args.dictionaryCandidates
|
||||
? '--dictionary-candidates'
|
||||
: args.dictionarySelect
|
||||
? '--dictionary-select'
|
||||
: '--dictionary',
|
||||
];
|
||||
if (args.dictionarySelect) {
|
||||
if (!args.dictionaryAnilistId) {
|
||||
throw new Error('Dictionary selection requires an AniList media ID.');
|
||||
}
|
||||
forwarded.push('--dictionary-anilist-id', String(args.dictionaryAnilistId));
|
||||
}
|
||||
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
|
||||
forwarded.push('--dictionary-target', args.dictionaryTarget);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ function createContext(): LauncherCommandContext {
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
|
||||
@@ -129,6 +129,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('<target>', 'Video file path or anime directory path')
|
||||
.description('Generate or correct character dictionary AniList matches')
|
||||
.argument('[target]', 'Video file path or anime directory path')
|
||||
.option('--candidates', 'List AniList candidates for a character dictionary target')
|
||||
.option('--select <id>', 'Pin an AniList media ID for the target series')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((target: string, options: Record<string, unknown>) => {
|
||||
.action((target: string | undefined, options: Record<string, unknown>) => {
|
||||
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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -415,6 +415,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
] as const;
|
||||
|
||||
@@ -76,6 +76,32 @@ test('guessAnilistMediaInfo joins multi-part guessit titles', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo preserves useful guessit alternative title for ambiguous Re ZERO filenames', async () => {
|
||||
const result = await guessAnilistMediaInfo(
|
||||
'/tmp/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv',
|
||||
null,
|
||||
{
|
||||
runGuessit: async () =>
|
||||
JSON.stringify({
|
||||
title: 'Re',
|
||||
alternative_title: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
|
||||
@@ -7,6 +7,8 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
|
||||
export interface AnilistMediaGuess {
|
||||
title: string;
|
||||
alternativeTitle?: string;
|
||||
year?: number;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
source: 'guessit' | 'fallback';
|
||||
@@ -131,6 +133,20 @@ function firstPositiveInteger(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstYear(value: unknown): number | undefined {
|
||||
const candidate = firstPositiveInteger(value);
|
||||
if (candidate === null) return undefined;
|
||||
return candidate >= 1900 && candidate <= 2200 ? candidate : undefined;
|
||||
}
|
||||
|
||||
function buildGuessitTitle(title: string, alternativeTitle: string | null): string {
|
||||
if (!alternativeTitle) return title;
|
||||
if (title.length <= 3) {
|
||||
return `${title} ${alternativeTitle}`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function normalizeTitle(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -215,10 +231,19 @@ export async function guessAnilistMediaInfo(
|
||||
const stdout = await deps.runGuessit(guessitTarget);
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
const title = readGuessitTitle(parsed.title);
|
||||
const alternativeTitle = readGuessitTitle(parsed.alternative_title);
|
||||
const episode = firstPositiveInteger(parsed.episode);
|
||||
const season = firstPositiveInteger(parsed.season);
|
||||
const year = firstYear(parsed.year);
|
||||
if (title) {
|
||||
return { title, season, episode, source: 'guessit' };
|
||||
return {
|
||||
title: buildGuessitTitle(title, alternativeTitle),
|
||||
...(alternativeTitle ? { alternativeTitle } : {}),
|
||||
...(year ? { year } : {}),
|
||||
season,
|
||||
episode,
|
||||
source: 'guessit',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore guessit failures and fall back to internal parser.
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -48,6 +49,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
refreshKnownWords: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
openCharacterDictionary: false,
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
@@ -50,6 +51,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
@@ -199,6 +203,19 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
getCharacterDictionarySelection: async () => ({
|
||||
seriesKey: 'test',
|
||||
guessTitle: 'Test',
|
||||
current: { id: 1, title: 'Test', episodes: 12 },
|
||||
override: null,
|
||||
candidates: [{ id: 1, title: 'Test', episodes: 12 }],
|
||||
}),
|
||||
setCharacterDictionarySelection: async () => ({
|
||||
ok: true,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 1, title: 'Test', episodes: 12 },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
runStatsCommand: async () => {
|
||||
calls.push('runStatsCommand');
|
||||
},
|
||||
@@ -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<Partial<CliArgs>> = [
|
||||
{ start: true },
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||
|
||||
export type CharacterDictionaryCandidate = {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySelectionSnapshot = {
|
||||
seriesKey: string;
|
||||
guessTitle: string | null;
|
||||
current: CharacterDictionaryCandidate | null;
|
||||
override: CharacterDictionaryCandidate | null;
|
||||
candidates: CharacterDictionaryCandidate[];
|
||||
};
|
||||
|
||||
export type CharacterDictionarySelectionResult = {
|
||||
ok: boolean;
|
||||
seriesKey: string;
|
||||
selected: CharacterDictionaryCandidate;
|
||||
staleMediaIds: number[];
|
||||
};
|
||||
|
||||
export interface CliCommandServiceDeps {
|
||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
getMpvSocketPath: () => string;
|
||||
@@ -64,6 +85,13 @@ export interface CliCommandServiceDeps {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
getCharacterDictionarySelection: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setCharacterDictionarySelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionarySelectionResult>;
|
||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
@@ -162,6 +190,11 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
getSelection: (targetPath?: string) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setSelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionarySelectionResult>;
|
||||
};
|
||||
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 <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) {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface IpcServiceDeps {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -211,6 +213,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -284,6 +288,23 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||
getCharacterDictionarySelection:
|
||||
options.getCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
})),
|
||||
setCharacterDictionarySelection:
|
||||
options.setCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
ok: false,
|
||||
seriesKey: '',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
})),
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||
@@ -570,6 +591,31 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return await deps.retryAnilistQueueNow();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
|
||||
return await (deps.getCharacterDictionarySelection?.() ??
|
||||
Promise.resolve({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
}));
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.setCharacterDictionarySelection,
|
||||
async (_event, mediaId: unknown) => {
|
||||
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
|
||||
return { ok: false, message: 'Invalid AniList media ID.' };
|
||||
}
|
||||
return await (deps.setCharacterDictionarySelection?.(mediaId as number) ??
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
message: 'Character dictionary selection unavailable.',
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
@@ -45,6 +46,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
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: () => {},
|
||||
}),
|
||||
|
||||
@@ -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<void>;
|
||||
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: () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): 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: () => {},
|
||||
}),
|
||||
|
||||
@@ -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<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SessionActionExecutorDeps {
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
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;
|
||||
|
||||
@@ -18,6 +18,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -48,6 +49,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
29
src/main.ts
29
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),
|
||||
|
||||
@@ -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<CharacterDictionarySnapshotResult>;
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
getManualSelectionSnapshot: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
||||
setManualSelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionaryManualSelectionResult>;
|
||||
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<void>) => {
|
||||
let hasAniListRequest = false;
|
||||
return async (): Promise<void> => {
|
||||
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<void>,
|
||||
): Promise<ResolvedAniListMedia> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
|
||||
@@ -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<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
@@ -208,6 +232,69 @@ export async function resolveAniListMediaIdFromGuess(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function searchAniListMediaCandidates(
|
||||
title: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<AniListMediaCandidate[]> {
|
||||
const data = await fetchAniList<AniListSearchResponse>(
|
||||
`
|
||||
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<void>,
|
||||
): Promise<AniListMediaCandidate> {
|
||||
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<void>,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
122
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
122
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
@@ -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<number>();
|
||||
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<CharacterDictionaryManualSelection>;
|
||||
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<string, CharacterDictionaryManualSelection>();
|
||||
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<CharacterDictionaryManualSelection | null> => {
|
||||
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
|
||||
},
|
||||
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
||||
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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
isOverlayShortcutContextActive?: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
@@ -49,6 +50,9 @@ export function createOverlayShortcutsRuntimeService(
|
||||
openRuntimeOptions: () => {
|
||||
input.openRuntimeOptionsPalette();
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
input.openCharacterDictionary();
|
||||
},
|
||||
openJimaku: () => {
|
||||
input.openJimaku();
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
48
src/main/runtime/character-dictionary-open.ts
Normal file
48
src/main/runtime/character-dictionary-open.ts
Normal file
@@ -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<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
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,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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,
|
||||
|
||||
@@ -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<void>;
|
||||
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),
|
||||
|
||||
@@ -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<void>;
|
||||
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,
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -62,6 +63,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -124,6 +124,9 @@ function createQueuedIpcListenerWithPayload<T>(
|
||||
|
||||
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<YoutubePickerResolveResult> =>
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -365,6 +365,7 @@ function createKeyboardHandlerHarness() {
|
||||
|
||||
const handlers = createKeyboardHandlers(ctx as never, {
|
||||
handleRuntimeOptionsKeydown: () => false,
|
||||
handleCharacterDictionaryKeydown: () => false,
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -197,6 +197,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content character-dictionary-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Character Dictionary Anime</div>
|
||||
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
||||
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
|
||||
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
||||
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
144
src/renderer/modals/character-dictionary.test.ts
Normal file
144
src/renderer/modals/character-dictionary.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CharacterDictionarySelectionSnapshot, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createCharacterDictionaryModal } from './character-dictionary.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => entries.forEach((entry) => tokens.add(entry)),
|
||||
remove: (...entries: string[]) => entries.forEach((entry) => tokens.delete(entry)),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === undefined) {
|
||||
if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
return;
|
||||
}
|
||||
if (force) tokens.add(entry);
|
||||
else tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
return {
|
||||
className: '',
|
||||
textContent: '',
|
||||
type: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(),
|
||||
append(...children: unknown[]) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createNodeStub(hidden = false) {
|
||||
return {
|
||||
textContent: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(hidden ? ['hidden'] : []),
|
||||
setAttribute: () => {},
|
||||
addEventListener: () => {},
|
||||
replaceChildren(...children: unknown[]) {
|
||||
this.children = [...children];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('character dictionary modal loads candidates and applies selected override', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const snapshot: CharacterDictionarySelectionSnapshot = {
|
||||
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 }],
|
||||
};
|
||||
const calls: number[] = [];
|
||||
const overlay = createNodeStub();
|
||||
const modalNode = createNodeStub(true);
|
||||
const candidates = createNodeStub();
|
||||
const status = createNodeStub();
|
||||
const state = createRendererState();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getCharacterDictionarySelection: async () => snapshot,
|
||||
setCharacterDictionarySelection: async (mediaId: number) => {
|
||||
calls.push(mediaId);
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey: snapshot.seriesKey,
|
||||
selected: snapshot.candidates[0]!,
|
||||
staleMediaIds: [10607],
|
||||
};
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
| 'notifyOverlayModalClosed'
|
||||
>,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = createCharacterDictionaryModal(
|
||||
{
|
||||
state,
|
||||
dom: {
|
||||
overlay,
|
||||
characterDictionaryModal: modalNode,
|
||||
characterDictionaryClose: createNodeStub(),
|
||||
characterDictionarySummary: createNodeStub(),
|
||||
characterDictionaryCurrent: createNodeStub(),
|
||||
characterDictionaryCandidates: candidates,
|
||||
characterDictionaryStatus: status,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
await modal.openCharacterDictionaryModal();
|
||||
assert.equal(state.characterDictionaryModalOpen, true);
|
||||
assert.equal(overlay.classList.contains('interactive'), true);
|
||||
assert.equal(modalNode.classList.contains('hidden'), false);
|
||||
assert.equal(candidates.children.length, 1);
|
||||
|
||||
modal.handleCharacterDictionaryKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(calls, [21355]);
|
||||
assert.match(status.textContent, /Override saved/);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
224
src/renderer/modals/character-dictionary.ts
Normal file
224
src/renderer/modals/character-dictionary.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type {
|
||||
CharacterDictionaryCandidate,
|
||||
CharacterDictionarySelectionSnapshot,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
function formatCandidate(candidate: CharacterDictionaryCandidate | null): string {
|
||||
if (!candidate) return 'None';
|
||||
const episodes = candidate.episodes === null ? '?' : String(candidate.episodes);
|
||||
return `${candidate.id} - ${candidate.title} (${episodes} episodes)`;
|
||||
}
|
||||
|
||||
function buildSummary(snapshot: CharacterDictionarySelectionSnapshot): string {
|
||||
const guess = snapshot.guessTitle ?? 'No active title';
|
||||
return `Series key: ${snapshot.seriesKey} · Guess: ${guess}`;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.state.characterDictionaryStatus = message;
|
||||
ctx.dom.characterDictionaryStatus.textContent = message;
|
||||
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
|
||||
const previousId =
|
||||
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
|
||||
?.id;
|
||||
ctx.state.characterDictionarySelection = snapshot;
|
||||
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
|
||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||
nextIndex >= 0 ? nextIndex : 0,
|
||||
snapshot.candidates.length,
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'character-dictionary-candidate';
|
||||
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'runtime-options-label';
|
||||
main.textContent = candidate.title;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'runtime-options-allowed';
|
||||
const episodeLabel = candidate.episodes === null ? '?' : String(candidate.episodes);
|
||||
meta.textContent = `AniList ${candidate.id} · ${episodeLabel} episodes`;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'character-dictionary-use';
|
||||
button.type = 'button';
|
||||
button.textContent = 'Use';
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
void applySelectedCandidate();
|
||||
});
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'character-dictionary-candidate-body';
|
||||
body.append(main, meta);
|
||||
|
||||
item.append(body, button);
|
||||
item.addEventListener('click', () => {
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
render();
|
||||
});
|
||||
item.addEventListener('dblclick', () => {
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
void applySelectedCandidate();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const snapshot = ctx.state.characterDictionarySelection;
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||
if (!snapshot) {
|
||||
ctx.dom.characterDictionarySummary.textContent = '';
|
||||
ctx.dom.characterDictionaryCurrent.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionarySummary.textContent = buildSummary(snapshot);
|
||||
ctx.dom.characterDictionaryCurrent.textContent = `Current: ${formatCandidate(
|
||||
snapshot.current,
|
||||
)} · Override: ${formatCandidate(snapshot.override)}`;
|
||||
|
||||
if (snapshot.candidates.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'character-dictionary-empty';
|
||||
empty.textContent = 'No AniList candidates found.';
|
||||
ctx.dom.characterDictionaryCandidates.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren(
|
||||
...snapshot.candidates.map((candidate, index) => renderCandidate(candidate, index)),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshSelection(): Promise<void> {
|
||||
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
|
||||
setSelection(snapshot);
|
||||
setStatus(
|
||||
snapshot.override
|
||||
? `Override active: ${formatCandidate(snapshot.override)}`
|
||||
: 'Select the correct AniList entry.',
|
||||
);
|
||||
}
|
||||
|
||||
async function applySelectedCandidate(): Promise<void> {
|
||||
const snapshot = ctx.state.characterDictionarySelection;
|
||||
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
|
||||
if (!candidate) return;
|
||||
|
||||
setStatus(`Saving override for ${candidate.title}...`);
|
||||
try {
|
||||
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
|
||||
if (!result.ok) {
|
||||
setStatus('Failed to save override', true);
|
||||
return;
|
||||
}
|
||||
await refreshSelection();
|
||||
const staleLabel =
|
||||
result.staleMediaIds.length > 0
|
||||
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
||||
: '';
|
||||
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
function showShell(): void {
|
||||
ctx.state.characterDictionaryModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.characterDictionaryModal.classList.remove('hidden');
|
||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
|
||||
setStatus('Loading AniList candidates...');
|
||||
}
|
||||
|
||||
async function openCharacterDictionaryModal(): Promise<void> {
|
||||
if (!ctx.state.characterDictionaryModalOpen) {
|
||||
showShell();
|
||||
} else {
|
||||
setStatus('Refreshing AniList candidates...');
|
||||
}
|
||||
await refreshSelection();
|
||||
}
|
||||
|
||||
function closeCharacterDictionaryModal(): void {
|
||||
if (!ctx.state.characterDictionaryModalOpen) return;
|
||||
ctx.state.characterDictionaryModalOpen = false;
|
||||
ctx.state.characterDictionarySelection = null;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.characterDictionaryModal.classList.add('hidden');
|
||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
||||
setStatus('');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: -1 | 1): void {
|
||||
const length = ctx.state.characterDictionarySelection?.candidates.length ?? 0;
|
||||
if (length <= 0) return;
|
||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||
ctx.state.characterDictionarySelectedIndex + delta,
|
||||
length,
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
function handleCharacterDictionaryKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeCharacterDictionaryModal();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void applySelectedCandidate();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||
|
||||
return {
|
||||
openCharacterDictionaryModal,
|
||||
closeCharacterDictionaryModal,
|
||||
handleCharacterDictionaryKeydown,
|
||||
};
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const OVERLAY_SHORTCUTS: Array<{
|
||||
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
|
||||
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'openSessionHelp', label: 'Open session help' },
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
@@ -71,6 +72,7 @@ function isAnySettingsModalOpen(): boolean {
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.characterDictionaryModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
@@ -87,6 +89,7 @@ function isAnyModalOpen(): boolean {
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.characterDictionaryModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.youtubePickerModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen ||
|
||||
@@ -114,6 +117,10 @@ const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -165,6 +172,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
@@ -221,6 +229,7 @@ function getActiveModal(): string | null {
|
||||
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
if (ctx.state.characterDictionaryModalOpen) return 'character-dictionary';
|
||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
||||
if (ctx.state.sessionHelpModalOpen) return 'session-help';
|
||||
return null;
|
||||
@@ -248,6 +257,9 @@ function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
||||
}
|
||||
if (ctx.state.characterDictionaryModalOpen) {
|
||||
characterDictionaryModal.closeCharacterDictionaryModal();
|
||||
}
|
||||
if (ctx.state.subsyncModalOpen) {
|
||||
subsyncModal.closeSubsyncModal();
|
||||
}
|
||||
@@ -435,6 +447,12 @@ function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenCharacterDictionary(() => {
|
||||
runGuardedAsync('character-dictionary:open', async () => {
|
||||
await characterDictionaryModal.openCharacterDictionaryModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenSessionHelp(() => {
|
||||
runGuarded('session-help:open', () => {
|
||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
CharacterDictionarySelectionSnapshot,
|
||||
SubtitlePosition,
|
||||
SubtitleSidebarConfig,
|
||||
SubtitleCue,
|
||||
@@ -64,6 +65,11 @@ export type RendererState = {
|
||||
runtimeOptionSelectedIndex: number;
|
||||
runtimeOptionDraftValues: Map<RuntimeOptionId, RuntimeOptionValue>;
|
||||
|
||||
characterDictionaryModalOpen: boolean;
|
||||
characterDictionarySelection: CharacterDictionarySelectionSnapshot | null;
|
||||
characterDictionarySelectedIndex: number;
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
@@ -169,6 +175,11 @@ export function createRendererState(): RendererState {
|
||||
runtimeOptionSelectedIndex: 0,
|
||||
runtimeOptionDraftValues: new Map(),
|
||||
|
||||
characterDictionaryModalOpen: false,
|
||||
characterDictionarySelection: null,
|
||||
characterDictionarySelectedIndex: 0,
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
@@ -1463,6 +1463,71 @@ iframe[id^='yomitan-popup'],
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.character-dictionary-content {
|
||||
width: min(680px, 92%);
|
||||
}
|
||||
|
||||
.character-dictionary-current {
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.character-dictionary-candidates {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(110, 115, 141, 0.2);
|
||||
border-radius: 8px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.character-dictionary-candidate,
|
||||
.character-dictionary-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(110, 115, 141, 0.1);
|
||||
}
|
||||
|
||||
.character-dictionary-candidate {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.character-dictionary-candidate:last-child,
|
||||
.character-dictionary-empty:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.character-dictionary-candidate.active {
|
||||
background: rgba(138, 173, 244, 0.15);
|
||||
}
|
||||
|
||||
.character-dictionary-candidate-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.character-dictionary-use {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(138, 173, 244, 0.38);
|
||||
border-radius: 6px;
|
||||
background: rgba(54, 58, 79, 0.8);
|
||||
color: var(--ctp-text);
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.character-dictionary-use:hover {
|
||||
background: rgba(91, 96, 120, 0.9);
|
||||
}
|
||||
|
||||
.character-dictionary-empty {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.controller-select-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -57,6 +57,13 @@ export type RendererDom = {
|
||||
runtimeOptionsList: HTMLUListElement;
|
||||
runtimeOptionsStatus: HTMLDivElement;
|
||||
|
||||
characterDictionaryModal: HTMLDivElement;
|
||||
characterDictionaryClose: HTMLButtonElement;
|
||||
characterDictionarySummary: HTMLDivElement;
|
||||
characterDictionaryCurrent: HTMLDivElement;
|
||||
characterDictionaryCandidates: HTMLUListElement;
|
||||
characterDictionaryStatus: HTMLDivElement;
|
||||
|
||||
subsyncModal: HTMLDivElement;
|
||||
subsyncCloseButton: HTMLButtonElement;
|
||||
subsyncEngineAlass: HTMLInputElement;
|
||||
@@ -177,6 +184,15 @@ export function resolveRendererDom(): RendererDom {
|
||||
runtimeOptionsList: getRequiredElement<HTMLUListElement>('runtimeOptionsList'),
|
||||
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>('runtimeOptionsStatus'),
|
||||
|
||||
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
||||
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
||||
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
||||
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
|
||||
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
||||
'characterDictionaryCandidates',
|
||||
),
|
||||
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
|
||||
|
||||
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
|
||||
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
|
||||
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
|
||||
|
||||
@@ -12,6 +12,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'controller-debug',
|
||||
'subtitle-sidebar',
|
||||
'session-help',
|
||||
'character-dictionary',
|
||||
] as const;
|
||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||
|
||||
@@ -70,6 +71,8 @@ export const IPC_CHANNELS = {
|
||||
openAnilistSetup: 'anilist:open-setup',
|
||||
getAnilistQueueStatus: 'anilist:get-queue-status',
|
||||
retryAnilistNow: 'anilist:retry-now',
|
||||
getCharacterDictionarySelection: 'character-dictionary:get-selection',
|
||||
setCharacterDictionarySelection: 'character-dictionary:set-selection',
|
||||
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
|
||||
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
|
||||
appendPlaylistBrowserFile: 'playlist-browser:append-file',
|
||||
@@ -113,6 +116,7 @@ export const IPC_CHANNELS = {
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
characterDictionaryOpen: 'character-dictionary:open',
|
||||
controllerSelectOpen: 'controller-select:open',
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||
|
||||
@@ -31,6 +31,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'toggleSubtitleSidebar',
|
||||
'openRuntimeOptions',
|
||||
'openSessionHelp',
|
||||
'openCharacterDictionary',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface ShortcutsConfig {
|
||||
multiCopyTimeoutMs?: number;
|
||||
toggleSecondarySub?: string | null;
|
||||
markAudioCard?: string | null;
|
||||
openCharacterDictionary?: string | null;
|
||||
openRuntimeOptions?: string | null;
|
||||
openJimaku?: string | null;
|
||||
openSessionHelp?: string | null;
|
||||
|
||||
@@ -341,6 +341,27 @@ export interface SessionActionDispatchRequest {
|
||||
|
||||
export type ResolvedControllerConfig = ResolvedConfig['controller'];
|
||||
|
||||
export interface CharacterDictionaryCandidate {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterDictionarySelectionSnapshot {
|
||||
seriesKey: string;
|
||||
guessTitle: string | null;
|
||||
current: CharacterDictionaryCandidate | null;
|
||||
override: CharacterDictionaryCandidate | null;
|
||||
candidates: CharacterDictionaryCandidate[];
|
||||
}
|
||||
|
||||
export interface CharacterDictionarySelectionResult {
|
||||
ok: boolean;
|
||||
seriesKey: string;
|
||||
selected: CharacterDictionaryCandidate;
|
||||
staleMediaIds: number[];
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -410,6 +431,7 @@ export interface ElectronAPI {
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||
onOpenCharacterDictionary: (callback: () => void) => void;
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
@@ -426,6 +448,8 @@ export interface ElectronAPI {
|
||||
youtubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
getCharacterDictionarySelection: () => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
|
||||
notifyOverlayModalClosed: (
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
@@ -437,7 +461,8 @@ export interface ElectronAPI {
|
||||
| 'controller-select'
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help',
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
) => void;
|
||||
notifyOverlayModalOpened: (
|
||||
modal:
|
||||
@@ -450,7 +475,8 @@ export interface ElectronAPI {
|
||||
| 'controller-select'
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help',
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SessionActionId =
|
||||
| 'markAudioCard'
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
| 'openCharacterDictionary'
|
||||
| 'openControllerSelect'
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
|
||||
Reference in New Issue
Block a user