mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
Compare commits
6 Commits
v0.15.0-beta.5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f7abcedd75
|
|||
| 807c0ff3db | |||
| 7e6f9672cf | |||
|
9fe13601fb
|
|||
| 920cbab1bc | |||
| 17d97f0b7e |
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: dictionary
|
||||
|
||||
- Keep character dictionary lookup entries scoped to generated Japanese name aliases instead of surfacing raw romanized/English aliases as separate results, and refresh cached v15 snapshots so old English-name entries are regenerated.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: character-dictionary
|
||||
|
||||
- **Character Dictionary:** Changed the in-app AniList selector to wait for an explicit title search. The search box is prefilled from the current filename guess, so you can edit it before choosing an override.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Added optional inline AniList portraits for character-name subtitle matches, including automatic refresh of cached character dictionary snapshots that do not contain portrait data.
|
||||
- Scoped manual AniList overrides by parent media directory, so separate season folders can keep separate character dictionary selections.
|
||||
- Fixed large character dictionary imports by serving the merged ZIP through a local URL when supported, with a base64 fallback for older bundled Yomitan builds.
|
||||
- Allowed subtitle overlay data image sources so inline character portraits render instead of showing a broken image icon.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Hid the visible subtitle overlay as soon as the character dictionary modal opens, including while AniList lookup is still loading or returns no results.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Windows managed mpv launches from a background SubMiner instance so the existing warm app receives the start command, retargets the new mpv socket, binds to the player window, and receives startup overlay options.
|
||||
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
area: updater
|
||||
|
||||
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
|
||||
- Fixed macOS tray update checks for builds that cannot install native app updates, so newer stable or prerelease GitHub releases are reported instead of incorrectly saying the current build is up to date.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: release
|
||||
|
||||
- Fixed macOS updater metadata mismatches by giving macOS and Windows ZIP release assets distinct build-time filenames.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: tray
|
||||
|
||||
- Fixed the Windows tray "Open SubMiner Setup" action so it opens the setup window after first-run setup is already complete.
|
||||
@@ -384,6 +384,7 @@
|
||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||
|
||||
@@ -91,21 +91,26 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
|
||||
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
|
||||
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
||||
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
||||
5. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
|
||||
|
||||
Older snapshot schema versions are regenerated automatically. Current-version snapshots are normally reused, but when `subtitleStyle.nameMatchImagesEnabled` is enabled SubMiner also checks whether the cached snapshot contains usable character portrait data. If it does not, the snapshot is refreshed so the merged dictionary can include images.
|
||||
|
||||
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------------- | --------- | ---------------------------------- |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||
| Option | Default | Description |
|
||||
| -------------------------------------- | --------- | ----------------------------------------- |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||
|
||||
## Dictionary Entries
|
||||
|
||||
Each character entry in the Yomitan dictionary includes structured content:
|
||||
|
||||
- **Name** — native (Japanese) and romanized forms
|
||||
- **Name** — the matched Japanese name form
|
||||
- **Known names** — generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
|
||||
- **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70)
|
||||
- **Portrait** — character image from AniList, embedded in the ZIP
|
||||
- **Description** — biography text from AniList (collapsible)
|
||||
@@ -169,10 +174,13 @@ This creates a standalone dictionary ZIP for the target media and saves it along
|
||||
|
||||
## 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.
|
||||
SubMiner uses `guessit` to infer the anime title from the active filename before searching 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:
|
||||
|
||||
- In-app: open the selector with `Ctrl/Cmd+Alt+A` or `--open-character-dictionary`, edit the prefilled title if needed, then search and choose the correct result.
|
||||
- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
|
||||
|
||||
```bash
|
||||
# List candidate AniList matches for a file
|
||||
subminer dictionary --candidates "/path/to/episode.mkv"
|
||||
@@ -188,7 +196,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
||||
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.
|
||||
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. 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
|
||||
|
||||
@@ -207,7 +215,7 @@ character-dictionaries/
|
||||
m170942-va67890.jpg # Voice actor portrait
|
||||
```
|
||||
|
||||
**Snapshot format** (v15): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
|
||||
**Snapshot format** (v16): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
|
||||
|
||||
**ZIP structure** follows the Yomitan dictionary format:
|
||||
|
||||
@@ -231,6 +239,7 @@ merged.zip
|
||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||
|
||||
## Reference Implementation
|
||||
@@ -253,8 +262,9 @@ If you work with visual novels or want a standalone dictionary generator indepen
|
||||
## Troubleshooting
|
||||
|
||||
- **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.
|
||||
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed.
|
||||
- **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:** 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`.
|
||||
- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`), edit the search title, and select the right AniList entry. You can also 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.
|
||||
|
||||
|
||||
+43
-41
@@ -371,34 +371,35 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
|
||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
|
||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
|
||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
||||
|
||||
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
|
||||
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
||||
@@ -420,6 +421,7 @@ In `single` mode all highlights use `singleColor`; in `banded` mode tokens map t
|
||||
Character-name highlighting is separate from N+1 and frequency highlighting:
|
||||
|
||||
- `nameMatchEnabled` controls whether SubMiner includes character-dictionary name matches in subtitle token metadata and renderer styling.
|
||||
- `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots.
|
||||
- `nameMatchColor` sets the highlight color for those matched character names.
|
||||
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled.
|
||||
|
||||
@@ -865,15 +867,15 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
||||
| `apiKey` | string | Static API key for the shared provider |
|
||||
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
||||
| `apiKey` | string | Static API key for the shared provider |
|
||||
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
||||
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
|
||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
|
||||
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
|
||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
|
||||
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
|
||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||
|
||||
SubMiner uses the shared provider for:
|
||||
|
||||
@@ -1125,12 +1127,12 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||
| Option | Values | Description |
|
||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
Customize it there, or set it to `null` to disable.
|
||||
|
||||
@@ -173,7 +173,7 @@ If you prefer to install it manually, see [manual launcher install](#manual-laun
|
||||
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
||||
|
||||
- `SubMiner-<version>.exe` — installer (recommended)
|
||||
- `SubMiner-<version>.zip` — portable fallback
|
||||
- `SubMiner-<version>-win.zip` — portable fallback
|
||||
|
||||
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@
|
||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||
|
||||
@@ -44,13 +44,15 @@ Character-name matches are built from the active merged SubMiner character dicti
|
||||
1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline.
|
||||
2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers.
|
||||
3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`.
|
||||
4. When `subtitleStyle.nameMatchImagesEnabled` is also enabled, SubMiner shows the cached AniList portrait beside matched names.
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------------- | --------- | ---------------------------------------- |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
||||
| Option | Default | Description |
|
||||
| -------------------------------------- | --------- | ------------------------------------------------ |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits next to name tokens |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
||||
|
||||
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
||||
|
||||
@@ -67,14 +69,14 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------------------------------ | ------------ | ---------------------------------------- |
|
||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | 5 colors[^1] | Array of five hex colors for banded mode |
|
||||
| Option | Default | Description |
|
||||
| ------------------------------------------------ | ------------ | ---------------------------------------------------------------- |
|
||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | 5 colors[^1] | Array of five hex colors for banded mode |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Custom path to frequency dictionary root (empty = auto-discover) |
|
||||
|
||||
[^1]: Default banded palette (most common → least common): `#ed8796`, `#f5a97f`, `#f9e2af`, `#8bd5ca`, `#8aadf4`.
|
||||
@@ -122,6 +124,7 @@ All annotation layers can be toggled at runtime via the mpv command menu without
|
||||
|
||||
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
||||
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
|
||||
- `subtitleStyle.nameMatchImagesEnabled` (`On` / `Off`)
|
||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
||||
|
||||
|
||||
+3
-2
@@ -88,10 +88,11 @@ Notes:
|
||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
||||
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
||||
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
||||
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
|
||||
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
|
||||
- Build config emits distinct ZIP names: `SubMiner-<version>-mac.zip` for the macOS Squirrel updater payload and `SubMiner-<version>-win.zip` for the Windows portable fallback. The user-facing DMG and Windows installer keep the unqualified `SubMiner-<version>` basename.
|
||||
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
|
||||
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
||||
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. Manual tray and launcher checks still use GitHub release metadata to report newer releases, but automatic notifications stay quiet when native app installation is unsupported. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
||||
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
||||
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js';
|
||||
import {
|
||||
launchAppBackgroundDetached,
|
||||
launchTexthookerOnly,
|
||||
runAppCommandWithInherit,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
||||
type AppCommandDeps = {
|
||||
platform: () => NodeJS.Platform;
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||
launchAppBackgroundDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
platform: () => process.platform,
|
||||
runAppCommandWithInherit,
|
||||
launchAppBackgroundDetached,
|
||||
};
|
||||
|
||||
export function runAppPassthroughCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: AppCommandDeps = defaultAppCommandDeps,
|
||||
): boolean {
|
||||
const { args, appPath } = context;
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
if (args.settings) {
|
||||
runAppCommandWithInherit(appPath, ['--settings']);
|
||||
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
return false;
|
||||
}
|
||||
runAppCommandWithInherit(appPath, args.appArgs);
|
||||
if (deps.platform() === 'darwin' && args.appArgs.length === 0) {
|
||||
deps.launchAppBackgroundDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
}
|
||||
deps.runAppCommandWithInherit(appPath, args.appArgs);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
||||
import { runDictionaryCommand } from './dictionary-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||
import { runAppPassthroughCommand } from './app-command.js';
|
||||
import { runStatsCommand } from './stats-command.js';
|
||||
import { runUpdateCommand } from './update-command.js';
|
||||
|
||||
@@ -168,6 +169,48 @@ test('doctor command forwards refresh-known-words to app binary', () => {
|
||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
||||
});
|
||||
|
||||
test('app command starts default macOS background app detached from launcher', () => {
|
||||
const context = createContext();
|
||||
context.args.appPassthrough = true;
|
||||
context.args.appArgs = [];
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
platform: () => 'darwin',
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']);
|
||||
});
|
||||
|
||||
test('app command keeps explicit passthrough args attached', () => {
|
||||
const context = createContext();
|
||||
context.args.appPassthrough = true;
|
||||
context.args.appArgs = ['--settings'];
|
||||
const forwarded: string[][] = [];
|
||||
const detached: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
platform: () => 'darwin',
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [['--settings']]);
|
||||
assert.deepEqual(detached, []);
|
||||
});
|
||||
|
||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||
const context = createContext();
|
||||
context.args.mpvStatus = true;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildMpvEnv,
|
||||
cleanupPlaybackSession,
|
||||
detectBackend,
|
||||
launchAppBackgroundDetached,
|
||||
findAppBinary,
|
||||
launchAppCommandDetached,
|
||||
launchTexthookerOnly,
|
||||
@@ -256,6 +257,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
||||
'--sub-file-paths=.;subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
@@ -424,6 +426,34 @@ test('launchAppCommandDetached handles child process spawn errors', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('launchAppBackgroundDetached starts background child directly', async () => {
|
||||
const { dir } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const argsPath = path.join(dir, 'args.txt');
|
||||
const envPath = path.join(dir, 'env.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
|
||||
`printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
launchAppBackgroundDetached(appPath, 'info');
|
||||
|
||||
const deadline = Date.now() + 1000;
|
||||
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background');
|
||||
assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
|
||||
const originalWrite = process.stdout.write;
|
||||
const writes: string[] = [];
|
||||
|
||||
+24
-5
@@ -47,13 +47,17 @@ type SpawnTarget = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||
type PathModule = Pick<
|
||||
typeof path,
|
||||
'dirname' | 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve' | 'isAbsolute' | 'normalize'
|
||||
>;
|
||||
|
||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
|
||||
export interface LauncherRuntimePluginPlan {
|
||||
scriptPath: string | null;
|
||||
@@ -62,6 +66,12 @@ export interface LauncherRuntimePluginPlan {
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
function resolvePluginCandidatePath(candidate: string, pathModule: PathModule): string {
|
||||
return pathModule.isAbsolute(candidate)
|
||||
? pathModule.normalize(candidate)
|
||||
: pathModule.resolve(candidate);
|
||||
}
|
||||
|
||||
export function parseMpvArgString(input: string): string[] {
|
||||
const chars = input;
|
||||
const args: string[] = [];
|
||||
@@ -291,12 +301,12 @@ export function resolveLauncherRuntimePluginPath(options: {
|
||||
pathModule?: typeof path;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
}): string | null {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const pathModule = options.pathModule ?? path;
|
||||
const existsSync = options.existsSync ?? fs.existsSync;
|
||||
const env = options.env ?? process.env;
|
||||
const dirname = options.dirname ?? __dirname;
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const candidates: string[] = [];
|
||||
|
||||
@@ -344,7 +354,7 @@ export function resolveLauncherRuntimePluginPath(options: {
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const resolved = pathModule.resolve(candidate);
|
||||
const resolved = resolvePluginCandidatePath(candidate, pathModule);
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
|
||||
@@ -1580,11 +1590,20 @@ export function launchAppStartDetached(appPath: string, logLevel: LogLevel): voi
|
||||
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
|
||||
}
|
||||
|
||||
export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void {
|
||||
const startArgs = ['--start', '--background'];
|
||||
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
|
||||
launchAppCommandDetached(appPath, startArgs, logLevel, 'app', {
|
||||
[BACKGROUND_CHILD_ENV]: '1',
|
||||
});
|
||||
}
|
||||
|
||||
export function launchAppCommandDetached(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LogLevel,
|
||||
label: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): void {
|
||||
if (maybeCaptureAppArgs(appArgs)) {
|
||||
return;
|
||||
@@ -1603,7 +1622,7 @@ export function launchAppCommandDetached(
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', stdoutFd, stderrFd],
|
||||
detached: true,
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||
@@ -1704,7 +1723,7 @@ export async function waitForUnixSocketReady(
|
||||
const deadline = nowMs() + timeoutMs;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(socketPath)) {
|
||||
if (process.platform === 'win32' || fs.existsSync(socketPath)) {
|
||||
const ready = await canConnectUnixSocket(socketPath);
|
||||
if (ready) return true;
|
||||
}
|
||||
|
||||
+2
-2
@@ -365,8 +365,8 @@ export function findRofiTheme(scriptPath: string): string | null {
|
||||
} else {
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
|
||||
candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE));
|
||||
candidates.push(path.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
|
||||
candidates.push(path.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
|
||||
candidates.push(path.posix.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
|
||||
candidates.push(path.posix.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
|
||||
}
|
||||
|
||||
candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE));
|
||||
|
||||
@@ -40,6 +40,19 @@ function writeExecutable(filePath: string, body: string): void {
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function writeFixtureExecutable(basePath: string, body: string): string {
|
||||
if (process.platform !== 'win32') {
|
||||
writeExecutable(basePath, body);
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const scriptPath = `${basePath}.js`;
|
||||
const commandPath = `${basePath}.cmd`;
|
||||
fs.writeFileSync(scriptPath, body);
|
||||
fs.writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
function createSmokeCase(name: string): SmokeCase {
|
||||
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke');
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
@@ -52,8 +65,8 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
|
||||
const socketPath = path.join(socketDir, 'subminer.sock');
|
||||
const videoPath = path.join(root, 'video.mkv');
|
||||
const fakeAppPath = path.join(binDir, 'fake-subminer');
|
||||
const fakeMpvPath = path.join(binDir, 'mpv');
|
||||
const fakeAppBasePath = path.join(binDir, 'fake-subminer');
|
||||
const fakeMpvBasePath = path.join(binDir, 'mpv');
|
||||
const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log');
|
||||
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
@@ -74,8 +87,8 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log');
|
||||
const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log');
|
||||
|
||||
writeExecutable(
|
||||
fakeMpvPath,
|
||||
const fakeMpvPath = writeFixtureExecutable(
|
||||
fakeMpvBasePath,
|
||||
`#!/usr/bin/env bun
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
@@ -113,8 +126,8 @@ process.on('SIGTERM', closeAndExit);
|
||||
`,
|
||||
);
|
||||
|
||||
writeExecutable(
|
||||
fakeAppPath,
|
||||
const fakeAppPath = writeFixtureExecutable(
|
||||
fakeAppBasePath,
|
||||
`#!/usr/bin/env bun
|
||||
const fs = require('node:fs');
|
||||
|
||||
@@ -157,14 +170,21 @@ process.exit(0);
|
||||
}
|
||||
|
||||
function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
|
||||
return {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: smokeCase.homeDir,
|
||||
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
||||
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
||||
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
|
||||
PATH: `${smokeCase.binDir}${path.delimiter}${process.env.PATH || ''}`,
|
||||
};
|
||||
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH';
|
||||
env[pathKey] = `${smokeCase.binDir}${path.delimiter}${env[pathKey] || ''}`;
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key !== pathKey && key.toLowerCase() === 'path') {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function runLauncher(
|
||||
|
||||
@@ -60,6 +60,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
|
||||
'--sub-file-paths=.;subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.15.0-beta.5",
|
||||
"version": "0.15.0-beta.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "subminer",
|
||||
"version": "0.15.0-beta.5",
|
||||
"version": "0.15.0-beta.6",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.15.0-beta.5",
|
||||
"version": "0.15.0-beta.6",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -158,6 +158,7 @@
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"artifactName": "SubMiner-${version}-mac.${ext}",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
@@ -174,7 +175,11 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"artifactName": "SubMiner-${version}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"artifactName": "SubMiner-${version}-win.${ext}",
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
@@ -182,6 +187,7 @@
|
||||
"icon": "assets/SubMiner.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "SubMiner-${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
|
||||
@@ -13,6 +13,16 @@ function M.create(ctx)
|
||||
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||
|
||||
local function is_windows()
|
||||
local platform = mp.get_property("platform") or ""
|
||||
if platform ~= "" then
|
||||
local normalized = platform:lower()
|
||||
if normalized == "windows" or normalized == "win32" then
|
||||
return true
|
||||
end
|
||||
if normalized == "macos" or normalized == "darwin" or normalized == "osx" or normalized == "linux" then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return package.config:sub(1, 1) == "\\"
|
||||
end
|
||||
|
||||
|
||||
@@ -33,6 +33,30 @@ local MODIFIER_MAP = {
|
||||
meta = "Meta",
|
||||
}
|
||||
|
||||
local SHIFTED_KEY_NAME_MAP = {
|
||||
Digit1 = "!",
|
||||
Digit2 = "@",
|
||||
Digit3 = "SHARP",
|
||||
Digit4 = "$",
|
||||
Digit5 = "%",
|
||||
Digit6 = "^",
|
||||
Digit7 = "&",
|
||||
Digit8 = "*",
|
||||
Digit9 = "(",
|
||||
Digit0 = ")",
|
||||
Minus = "_",
|
||||
Equal = "+",
|
||||
BracketLeft = "{",
|
||||
BracketRight = "}",
|
||||
Backslash = "|",
|
||||
Semicolon = ":",
|
||||
Quote = '"',
|
||||
Comma = "<",
|
||||
Period = ">",
|
||||
Slash = "?",
|
||||
Backquote = "~",
|
||||
}
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local utils = ctx.utils
|
||||
@@ -84,7 +108,22 @@ function M.create(ctx)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function key_spec_to_mpv_binding(key)
|
||||
local function contains_value(values, target)
|
||||
for _, value in ipairs(values) do
|
||||
if value == target then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function append_unique(values, value)
|
||||
if not contains_value(values, value) then
|
||||
values[#values + 1] = value
|
||||
end
|
||||
end
|
||||
|
||||
local function key_spec_to_mpv_bindings(key)
|
||||
if type(key) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
@@ -123,7 +162,24 @@ function M.create(ctx)
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = key_name
|
||||
return table.concat(parts, "+")
|
||||
local bindings = { table.concat(parts, "+") }
|
||||
|
||||
local shifted_key_name = SHIFTED_KEY_NAME_MAP[key.code]
|
||||
if has_shift and shifted_key_name then
|
||||
local shifted_parts = {}
|
||||
for _, modifier in ipairs(key.modifiers) do
|
||||
if modifier ~= "shift" then
|
||||
local mapped = MODIFIER_MAP[modifier]
|
||||
if mapped then
|
||||
shifted_parts[#shifted_parts + 1] = mapped
|
||||
end
|
||||
end
|
||||
end
|
||||
shifted_parts[#shifted_parts + 1] = shifted_key_name
|
||||
append_unique(bindings, table.concat(shifted_parts, "+"))
|
||||
end
|
||||
|
||||
return bindings
|
||||
end
|
||||
|
||||
local function build_cli_args(action_id, payload)
|
||||
@@ -294,13 +350,20 @@ function M.create(ctx)
|
||||
local generation = state.session_binding_generation
|
||||
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
local key_name = key_spec_to_mpv_binding(binding.key)
|
||||
if key_name then
|
||||
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding)
|
||||
end)
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
local name = "subminer-session-binding-"
|
||||
.. tostring(generation)
|
||||
.. "-"
|
||||
.. tostring(index)
|
||||
.. "-"
|
||||
.. tostring(key_index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding)
|
||||
end)
|
||||
end
|
||||
else
|
||||
subminer_log(
|
||||
"warn",
|
||||
|
||||
+16
-12
@@ -5,12 +5,14 @@
|
||||
|
||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections. Includes click-to-learn keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation settings remain config-file only.
|
||||
|
||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification, configurable notifications, and an opt-in prerelease channel. The `subminer` launcher and Linux rofi theme update automatically.
|
||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification, configurable update notifications, and an opt-in prerelease channel. The `subminer` launcher and Linux rofi theme update automatically. Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
|
||||
|
||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. First-run setup includes an Open SubMiner Settings button.
|
||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows, with an Open SubMiner Settings button on completion. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
|
||||
|
||||
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches. Bundled mpv plugin startup options are now configurable from SubMiner config.
|
||||
|
||||
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits. Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Sidebar appearance is configured via `subtitleSidebar.css`. The default subtitle font stack is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs are migrated automatically.
|
||||
@@ -25,6 +27,8 @@
|
||||
|
||||
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
||||
|
||||
- **Character Dictionary:** The in-app AniList title selector now waits for an explicit search rather than triggering automatically. The search box is prefilled from the current filename guess so it can be edited before confirming an override. Results are scoped to generated Japanese name aliases; raw romanized or English aliases no longer appear as separate entries.
|
||||
|
||||
- **Setup:** The bundled mpv runtime plugin readiness card is removed from first-run setup; the legacy mpv plugin removal notice still appears when needed.
|
||||
|
||||
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
||||
@@ -33,22 +37,24 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay also stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused.
|
||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; opens over fullscreen mpv without switching Spaces; and stays stable when mpv remains frontmost but window geometry temporarily disappears from macOS APIs. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay also stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused.
|
||||
|
||||
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. The visible overlay remains stacked above mpv after mpv regains focus from clicks, and is suspended while the in-player stats window is open.
|
||||
|
||||
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
|
||||
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
||||
|
||||
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting clear Japanese-vs-English cue timeline offsets. Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux.
|
||||
|
||||
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies. The bundled mpv plugin is injected when SubMiner auto-launches mpv for Jellyfin so mpv-side keybindings work without overlay focus. The `y-t` overlay toggle is reliable and remains sticky across stream redirects. Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
|
||||
|
||||
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position. Final progress reports use SubMiner's last known position when mpv resets during stop. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
||||
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position. Final progress reports use SubMiner's last known position when mpv resets during stop.
|
||||
|
||||
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity, and SubMiner always reports itself as the client regardless of legacy configurable identity fields.
|
||||
|
||||
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target. Library discovery works correctly when the app log level is set above info.
|
||||
|
||||
- **Jellyfin Setup:** Fixed the Jellyfin setup login flow on Windows: login now uses an IPC bridge with immediate progress feedback, and unreachable servers time out with an inline error instead of hanging.
|
||||
|
||||
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
||||
|
||||
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
||||
@@ -59,21 +65,21 @@
|
||||
|
||||
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
|
||||
|
||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
|
||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is now suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
|
||||
|
||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; and Windows retains the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups avoid Electron networking on Linux and macOS. Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
|
||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved.
|
||||
|
||||
- **Setup - macOS:** First-run setup now recognizes existing `subminer` installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
||||
|
||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running.
|
||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running. On Windows, the tray "Open SubMiner Setup" action now correctly opens the setup window after first-run setup is complete.
|
||||
|
||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang.
|
||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang. On Windows, managed mpv launches from a background SubMiner instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
||||
|
||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
||||
|
||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered.
|
||||
|
||||
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
||||
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
||||
|
||||
- **Overlay Restart:** The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates.
|
||||
|
||||
@@ -91,8 +97,6 @@
|
||||
|
||||
- **Settings:** Search now works across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. The note-fields note type picker defaults to the configured Anki deck's note type, then `Kiku`, then `Lapis`, leaving it blank for manual selection otherwise. User config files are preserved during legacy config compatibility handling. The generated example config uses the same CSS declaration paths written by the Settings window.
|
||||
|
||||
- **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same invocation.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`. Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, local dev version routes serve warmed archive files instead of redirecting to production, and internal README files no longer break archived builds.
|
||||
|
||||
@@ -322,7 +322,9 @@ end
|
||||
|
||||
local expected_cli_bindings = {
|
||||
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
|
||||
{ keys = "}", flag = "--shift-sub-delay-next-line" },
|
||||
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
|
||||
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
|
||||
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
||||
|
||||
@@ -64,6 +64,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||
assert.equal(config.anilist.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||
assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||
@@ -152,7 +153,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
||||
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
assert.equal(config.mpv.profile, '');
|
||||
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||
@@ -740,6 +741,44 @@ test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.nameMatchImagesEnabled and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchImagesEnabled": true
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.nameMatchImagesEnabled, true);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchImagesEnabled": "yes"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||
DEFAULT_CONFIG.subtitleStyle.nameMatchImagesEnabled,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.nameMatchImagesEnabled'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses anilist.enabled and warns for invalid value', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -11,6 +11,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
nameMatchEnabled: false,
|
||||
nameMatchImagesEnabled: false,
|
||||
nameMatchColor: '#f5bde6',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
|
||||
@@ -76,6 +76,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nameMatchImagesEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.nameMatchImagesEnabled,
|
||||
description:
|
||||
'Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nameMatchColor',
|
||||
kind: 'string',
|
||||
|
||||
@@ -190,6 +190,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||
const fallbackSubtitleStyleNameMatchImagesEnabled =
|
||||
resolved.subtitleStyle.nameMatchImagesEnabled;
|
||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||
@@ -390,6 +392,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const nameMatchImagesEnabled = asBoolean(
|
||||
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
|
||||
);
|
||||
if (nameMatchImagesEnabled !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchImagesEnabled = nameMatchImagesEnabled;
|
||||
} else if (
|
||||
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled !==
|
||||
undefined
|
||||
) {
|
||||
resolved.subtitleStyle.nameMatchImagesEnabled = fallbackSubtitleStyleNameMatchImagesEnabled;
|
||||
warn(
|
||||
'subtitleStyle.nameMatchImagesEnabled',
|
||||
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
|
||||
resolved.subtitleStyle.nameMatchImagesEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
if (nameMatchColor !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
||||
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
||||
|
||||
@@ -172,6 +172,31 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle nameMatchImagesEnabled accepts boolean and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchImagesEnabled: true,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.nameMatchImagesEnabled, true);
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchImagesEnabled: 'yes' as unknown as boolean,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.nameMatchImagesEnabled, false);
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nameMatchImagesEnabled' &&
|
||||
warning.message === 'Expected boolean.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
||||
const { context } = createResolveContext({});
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ test('settings registry exposes css declaration editor for primary and secondary
|
||||
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.nameMatchImagesEnabled').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
||||
|
||||
@@ -345,6 +345,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
path === 'subtitleStyle.knownWordColor' ||
|
||||
path === 'subtitleStyle.nPlusOneColor' ||
|
||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchImagesEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchColor'
|
||||
) {
|
||||
return { category: 'appearance', section: 'Annotation Display' };
|
||||
@@ -524,7 +525,11 @@ function subsectionForPath(path: string): string | undefined {
|
||||
) {
|
||||
return 'Frequency Highlighting';
|
||||
}
|
||||
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
||||
if (
|
||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchImagesEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchColor'
|
||||
) {
|
||||
return 'Character Names';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||
|
||||
@@ -80,7 +80,11 @@ export {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
isTabInputForMpvForwarding,
|
||||
} from './overlay-window-input';
|
||||
export { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
export {
|
||||
initializeOverlayAnkiIntegration,
|
||||
initializeOverlayRuntime,
|
||||
startOverlayWindowTracker,
|
||||
} from './overlay-runtime-init';
|
||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||
export {
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
|
||||
@@ -1191,18 +1191,22 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: number[] = [];
|
||||
const searches: Array<string | undefined> = [];
|
||||
|
||||
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 },
|
||||
],
|
||||
}),
|
||||
getCharacterDictionarySelection: async (searchTitle) => {
|
||||
searches.push(searchTitle);
|
||||
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: 24 },
|
||||
override: null,
|
||||
candidates: [
|
||||
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||
],
|
||||
};
|
||||
},
|
||||
setCharacterDictionarySelection: async (mediaId) => {
|
||||
calls.push(mediaId);
|
||||
return {
|
||||
@@ -1223,7 +1227,7 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
|
||||
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
|
||||
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
|
||||
|
||||
assert.deepEqual(await getHandler!({}), {
|
||||
assert.deepEqual(await getHandler!({}, ' Re:ZERO '), {
|
||||
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 },
|
||||
@@ -1241,4 +1245,5 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
assert.deepEqual(calls, [21355]);
|
||||
assert.deepEqual(searches, ['Re:ZERO']);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface IpcServiceDeps {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
@@ -223,7 +223,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
@@ -615,8 +615,9 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return await deps.retryAnilistQueueNow();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
|
||||
return await (deps.getCharacterDictionarySelection?.() ??
|
||||
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async (_event, searchTitle) => {
|
||||
const normalizedSearchTitle = typeof searchTitle === 'string' ? searchTitle.trim() : undefined;
|
||||
return await (deps.getCharacterDictionarySelection?.(normalizedSearchTitle) ??
|
||||
Promise.resolve({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
test('showMpvOsdRuntime sends show-text when connected', () => {
|
||||
const commands: (string | number)[][] = [];
|
||||
showMpvOsdRuntime(
|
||||
const shown = showMpvOsdRuntime(
|
||||
{
|
||||
connected: true,
|
||||
send: ({ command }) => {
|
||||
@@ -19,12 +19,13 @@ test('showMpvOsdRuntime sends show-text when connected', () => {
|
||||
},
|
||||
'hello',
|
||||
);
|
||||
assert.equal(shown, true);
|
||||
assert.deepEqual(commands, [['show-text', 'hello', '3000']]);
|
||||
});
|
||||
|
||||
test('showMpvOsdRuntime enables property expansion for placeholder-based messages', () => {
|
||||
const commands: (string | number)[][] = [];
|
||||
showMpvOsdRuntime(
|
||||
const shown = showMpvOsdRuntime(
|
||||
{
|
||||
connected: true,
|
||||
send: ({ command }) => {
|
||||
@@ -33,6 +34,7 @@ test('showMpvOsdRuntime enables property expansion for placeholder-based message
|
||||
},
|
||||
'Subtitle delay: ${sub-delay}',
|
||||
);
|
||||
assert.equal(shown, true);
|
||||
assert.deepEqual(commands, [
|
||||
['expand-properties', 'show-text', 'Subtitle delay: ${sub-delay}', '3000'],
|
||||
]);
|
||||
@@ -40,7 +42,7 @@ test('showMpvOsdRuntime enables property expansion for placeholder-based message
|
||||
|
||||
test('showMpvOsdRuntime logs fallback when disconnected', () => {
|
||||
const logs: string[] = [];
|
||||
showMpvOsdRuntime(
|
||||
const shown = showMpvOsdRuntime(
|
||||
{
|
||||
connected: false,
|
||||
send: () => {},
|
||||
@@ -50,6 +52,7 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => {
|
||||
logs.push(line);
|
||||
},
|
||||
);
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(logs, ['OSD (MPV not connected): hello']);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,15 +51,16 @@ export function showMpvOsdRuntime(
|
||||
mpvClient: MpvRuntimeClientLike | null,
|
||||
text: string,
|
||||
fallbackLog: (text: string) => void = (line) => logger.info(line),
|
||||
): void {
|
||||
): boolean {
|
||||
if (mpvClient && mpvClient.connected) {
|
||||
const command = text.includes('${')
|
||||
? ['expand-properties', 'show-text', text, '3000']
|
||||
: ['show-text', text, '3000'];
|
||||
mpvClient.send({ command });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
fallbackLog(`OSD (MPV not connected): ${text}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function replayCurrentSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
|
||||
|
||||
@@ -1,6 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
import {
|
||||
initializeOverlayAnkiIntegration,
|
||||
initializeOverlayRuntime,
|
||||
startOverlayWindowTracker,
|
||||
} from './overlay-runtime-init';
|
||||
|
||||
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
isTargetWindowMinimized: () => false,
|
||||
start: () => {
|
||||
calls.push('start');
|
||||
},
|
||||
};
|
||||
|
||||
const result = startOverlayWindowTracker({
|
||||
backendOverride: 'windows',
|
||||
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
|
||||
createWindowTracker: (override, socketPath) => {
|
||||
calls.push(`create:${override}:${socketPath}`);
|
||||
return tracker as never;
|
||||
},
|
||||
setWindowTracker: (nextTracker) => {
|
||||
calls.push(nextTracker === tracker ? 'set-tracker' : 'clear-tracker');
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('bounds');
|
||||
},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
calls.push('refresh-subtitle');
|
||||
},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, tracker);
|
||||
tracker.onWindowFound?.({ x: 10, y: 20, width: 300, height: 200 });
|
||||
tracker.onWindowFocusChange?.(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'create:windows:\\\\.\\pipe\\subminer-socket',
|
||||
'set-tracker',
|
||||
'start',
|
||||
'bounds',
|
||||
'visibility',
|
||||
'refresh-subtitle',
|
||||
'visibility',
|
||||
'sync-shortcuts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||
let createdIntegrations = 0;
|
||||
|
||||
@@ -25,6 +25,24 @@ type CreateAnkiIntegrationArgs = {
|
||||
knownWordCacheStatePath: string;
|
||||
};
|
||||
|
||||
export type OverlayWindowTrackerOptions = {
|
||||
backendOverride: string | null;
|
||||
getMpvSocketPath: () => string;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
};
|
||||
|
||||
function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiIntegrationLike {
|
||||
const { AnkiIntegration } =
|
||||
require('../../anki-integration') as typeof import('../../anki-integration');
|
||||
@@ -46,82 +64,80 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
);
|
||||
}
|
||||
|
||||
export function initializeOverlayRuntime(options: {
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => {
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
backendOverride: string | null;
|
||||
createMainWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}): void {
|
||||
options.createMainWindow();
|
||||
options.registerGlobalShortcuts();
|
||||
|
||||
export function startOverlayWindowTracker(
|
||||
options: OverlayWindowTrackerOptions,
|
||||
): BaseWindowTracker | null {
|
||||
const createWindowTrackerHandler = options.createWindowTracker ?? createWindowTracker;
|
||||
const windowTracker = createWindowTrackerHandler(
|
||||
options.backendOverride,
|
||||
options.getMpvSocketPath(),
|
||||
);
|
||||
options.setWindowTracker(windowTracker);
|
||||
if (windowTracker) {
|
||||
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.bindOverlayOwner?.();
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.refreshCurrentSubtitle?.();
|
||||
}
|
||||
};
|
||||
windowTracker.onWindowLost = () => {
|
||||
options.releaseOverlayOwner?.();
|
||||
if (windowTracker.isTargetWindowMinimized()) {
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
window.hide();
|
||||
}
|
||||
options.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
if (!windowTracker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.bindOverlayOwner?.();
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
};
|
||||
windowTracker.onWindowFocusChange = () => {
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.refreshCurrentSubtitle?.();
|
||||
}
|
||||
};
|
||||
windowTracker.onWindowLost = () => {
|
||||
options.releaseOverlayOwner?.();
|
||||
if (windowTracker.isTargetWindowMinimized()) {
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
window.hide();
|
||||
}
|
||||
options.syncOverlayShortcuts();
|
||||
};
|
||||
windowTracker.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
options.updateVisibleOverlayVisibility();
|
||||
};
|
||||
windowTracker.onWindowFocusChange = () => {
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
}
|
||||
options.syncOverlayShortcuts();
|
||||
};
|
||||
windowTracker.start();
|
||||
return windowTracker;
|
||||
}
|
||||
|
||||
export function initializeOverlayRuntime(
|
||||
options: OverlayWindowTrackerOptions & {
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => {
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
backendOverride: string | null;
|
||||
createMainWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
},
|
||||
): void {
|
||||
options.createMainWindow();
|
||||
options.registerGlobalShortcuts();
|
||||
|
||||
startOverlayWindowTracker(options);
|
||||
|
||||
initializeOverlayAnkiIntegration(options);
|
||||
|
||||
|
||||
@@ -149,6 +149,70 @@ test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async (
|
||||
assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle attaches character image metadata to name matches when enabled', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'アクアです',
|
||||
makeDepsFromYomitanTokens(
|
||||
[
|
||||
{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true },
|
||||
{ surface: 'です', reading: 'です', headword: 'です' },
|
||||
],
|
||||
{
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
getCharacterNameImage: (term) =>
|
||||
term === 'アクア'
|
||||
? {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アクア',
|
||||
}
|
||||
: null,
|
||||
} as Partial<TokenizerServiceDeps>,
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.tokens?.[0]?.characterImage, {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アクア',
|
||||
});
|
||||
assert.equal(result.tokens?.[1]?.characterImage, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps tokens when character image lookup throws', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'アクア',
|
||||
makeDepsFromYomitanTokens(
|
||||
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
|
||||
{
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
getCharacterNameImage: () => {
|
||||
throw new Error('image lookup failed');
|
||||
},
|
||||
} as Partial<TokenizerServiceDeps>,
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.[0]?.surface, 'アクア');
|
||||
assert.equal(result.tokens?.[0]?.characterImage, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle omits character image metadata when name-match images are disabled', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'アクア',
|
||||
makeDepsFromYomitanTokens(
|
||||
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
|
||||
{
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
getCharacterNameImage: () => ({
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アクア',
|
||||
}),
|
||||
} as Partial<TokenizerServiceDeps>,
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.[0]?.characterImage, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
|
||||
let lookupCalls = 0;
|
||||
const result = await tokenizeSubtitle(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mergeTokens } from '../../token-merger';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
FrequencyDictionaryMatchMode,
|
||||
CharacterNameImage,
|
||||
MergedToken,
|
||||
NPlusOneMatchMode,
|
||||
SubtitleData,
|
||||
@@ -48,6 +49,8 @@ export interface TokenizerServiceDeps {
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getNameMatchEnabled?: () => boolean;
|
||||
getNameMatchImagesEnabled?: () => boolean;
|
||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
@@ -80,6 +83,8 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getNameMatchEnabled?: () => boolean;
|
||||
getNameMatchImagesEnabled?: () => boolean;
|
||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
@@ -94,6 +99,7 @@ interface TokenizerAnnotationOptions {
|
||||
nPlusOneEnabled: boolean;
|
||||
jlptEnabled: boolean;
|
||||
nameMatchEnabled: boolean;
|
||||
nameMatchImagesEnabled: boolean;
|
||||
frequencyEnabled: boolean;
|
||||
frequencyMatchMode: FrequencyDictionaryMatchMode;
|
||||
minSentenceWordsForNPlusOne: number | undefined;
|
||||
@@ -229,6 +235,8 @@ export function createTokenizerDepsRuntime(
|
||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
getNameMatchEnabled: options.getNameMatchEnabled,
|
||||
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
||||
getCharacterNameImage: options.getCharacterNameImage,
|
||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||
getFrequencyRank: options.getFrequencyRank,
|
||||
@@ -684,6 +692,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
|
||||
nPlusOneEnabled,
|
||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
||||
nameMatchImagesEnabled: deps.getNameMatchImagesEnabled?.() === true,
|
||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
|
||||
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
|
||||
@@ -780,6 +789,53 @@ async function parseWithYomitanInternalParser(
|
||||
return enrichedTokens;
|
||||
}
|
||||
|
||||
function resolveCharacterNameImageForToken(
|
||||
token: MergedToken,
|
||||
getCharacterNameImage: (term: string) => CharacterNameImage | null,
|
||||
): CharacterNameImage | null {
|
||||
const terms = [token.headword, token.surface]
|
||||
.map((term) => term.trim())
|
||||
.filter((term, index, list) => term.length > 0 && list.indexOf(term) === index);
|
||||
for (const term of terms) {
|
||||
const image = getCharacterNameImage(term);
|
||||
if (image) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyCharacterNameImages(
|
||||
tokens: MergedToken[],
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): MergedToken[] {
|
||||
if (
|
||||
!options.nameMatchEnabled ||
|
||||
!options.nameMatchImagesEnabled ||
|
||||
typeof deps.getCharacterNameImage !== 'function'
|
||||
) {
|
||||
return tokens.map((token) => ({ ...token, characterImage: undefined }));
|
||||
}
|
||||
|
||||
const getCharacterNameImage = deps.getCharacterNameImage;
|
||||
return tokens.map((token) => {
|
||||
if (token.isNameMatch !== true) {
|
||||
return { ...token, characterImage: undefined };
|
||||
}
|
||||
let characterImage: CharacterNameImage | undefined;
|
||||
try {
|
||||
characterImage = resolveCharacterNameImageForToken(token, getCharacterNameImage) ?? undefined;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve character name image:', (err as Error).message);
|
||||
}
|
||||
return {
|
||||
...token,
|
||||
characterImage,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function tokenizeSubtitle(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
@@ -805,9 +861,10 @@ export async function tokenizeSubtitle(
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: annotatedTokens.length > 0 ? annotatedTokens : null,
|
||||
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -788,6 +788,30 @@ test('stripSubtitleAnnotationMetadata keeps known hover data while clearing non-
|
||||
});
|
||||
});
|
||||
|
||||
test('stripSubtitleAnnotationMetadata clears character image metadata from excluded name matches', () => {
|
||||
const token = makeToken({
|
||||
surface: 'は',
|
||||
headword: 'は',
|
||||
reading: 'ハ',
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
isNameMatch: true,
|
||||
});
|
||||
token.characterImage = {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'は',
|
||||
};
|
||||
|
||||
assert.deepEqual(stripSubtitleAnnotationMetadata(token), {
|
||||
...token,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: false,
|
||||
characterImage: undefined,
|
||||
jlptLevel: undefined,
|
||||
frequencyRank: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('stripSubtitleAnnotationMetadata leaves content tokens unchanged', () => {
|
||||
const token = makeToken({
|
||||
surface: '猫',
|
||||
|
||||
@@ -508,11 +508,17 @@ export function stripSubtitleAnnotationMetadata(
|
||||
return token;
|
||||
}
|
||||
|
||||
return {
|
||||
const strippedToken = {
|
||||
...token,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: false,
|
||||
jlptLevel: undefined,
|
||||
frequencyRank: undefined,
|
||||
};
|
||||
|
||||
if ('characterImage' in strippedToken) {
|
||||
strippedToken.characterImage = undefined;
|
||||
}
|
||||
|
||||
return strippedToken;
|
||||
}
|
||||
|
||||
@@ -1577,18 +1577,24 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
|
||||
assert.match(upsertScript ?? '', /"enabled":true/);
|
||||
});
|
||||
|
||||
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
|
||||
test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||
const zipPath = path.join(tempDir, 'dict.zip');
|
||||
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||
|
||||
const scripts: string[] = [];
|
||||
const servedArchives: string[] = [];
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
scripts.push(script);
|
||||
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
|
||||
if (urlMatch) {
|
||||
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
|
||||
servedArchives.push(await response.text());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
@@ -1611,15 +1617,103 @@ test('importYomitanDictionaryFromZip uses settings automation bridge instead of
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
|
||||
scripts.some((script) => script.includes('importDictionaryArchiveUrl')),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(servedArchives, ['zip-bytes']);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('subminerImportDictionary')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('importYomitanDictionaryFromZip falls back to base64 import for older Yomitan bridge', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||
const zipPath = path.join(tempDir, 'dict.zip');
|
||||
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||
|
||||
const scripts: string[] = [];
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
scripts.push(script);
|
||||
if (
|
||||
script.includes(
|
||||
'typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl',
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const deps = createDeps(async () => true, {
|
||||
createYomitanExtensionWindow: async (pageName: string) => {
|
||||
assert.equal(pageName, 'settings.html');
|
||||
return settingsWindow;
|
||||
},
|
||||
});
|
||||
|
||||
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(imported, true);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('importDictionaryArchiveUrl(')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('importYomitanDictionaryFromZip returns false when served archive cannot be read', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||
const zipPath = path.join(tempDir, 'dict.zip');
|
||||
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
|
||||
if (!urlMatch) return true;
|
||||
fs.unlinkSync(zipPath);
|
||||
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
|
||||
return response.ok;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const deps = createDeps(async () => true, {
|
||||
createYomitanExtensionWindow: async (pageName: string) => {
|
||||
assert.equal(pageName, 'settings.html');
|
||||
return settingsWindow;
|
||||
},
|
||||
});
|
||||
|
||||
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(imported, false);
|
||||
});
|
||||
|
||||
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
|
||||
const scripts: string[] = [];
|
||||
const settingsWindow = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
|
||||
@@ -705,6 +706,70 @@ async function invokeYomitanSettingsAutomation<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function serveDictionaryZipOnce<T>(
|
||||
zipPath: string,
|
||||
callback: (url: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const fileName = path.basename(zipPath);
|
||||
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const requestPath = `/${token}/${encodeURIComponent(fileName)}`;
|
||||
let served = false;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204, {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET, OPTIONS',
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url !== requestPath || served) {
|
||||
response.writeHead(404, { 'access-control-allow-origin': '*' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
served = true;
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(zipPath).size;
|
||||
} catch {
|
||||
response.writeHead(500, { 'access-control-allow-origin': '*' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'access-control-allow-origin': '*',
|
||||
'content-length': String(size),
|
||||
'content-type': 'application/zip',
|
||||
});
|
||||
const stream = fs.createReadStream(zipPath);
|
||||
stream.on('error', () => {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(500, { 'access-control-allow-origin': '*' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.destroy();
|
||||
});
|
||||
stream.pipe(response);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Dictionary import server did not bind to a TCP port.');
|
||||
}
|
||||
return await callback(`http://127.0.0.1:${address.port}${requestPath}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
||||
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
||||
@@ -1863,17 +1928,43 @@ export async function importYomitanDictionaryFromZip(
|
||||
return false;
|
||||
}
|
||||
|
||||
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
|
||||
const script = `
|
||||
(async () => {
|
||||
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
|
||||
${JSON.stringify(archiveBase64)},
|
||||
${JSON.stringify(path.basename(normalizedZipPath))}
|
||||
);
|
||||
return true;
|
||||
})();
|
||||
`;
|
||||
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
|
||||
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
|
||||
`
|
||||
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
|
||||
`,
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
|
||||
const result =
|
||||
supportsUrlImport === true
|
||||
? await serveDictionaryZipOnce(normalizedZipPath, async (archiveUrl) =>
|
||||
invokeYomitanSettingsAutomation<boolean>(
|
||||
`
|
||||
(async () => {
|
||||
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl(
|
||||
${JSON.stringify(archiveUrl)}
|
||||
);
|
||||
return true;
|
||||
})();
|
||||
`,
|
||||
deps,
|
||||
logger,
|
||||
),
|
||||
)
|
||||
: await invokeYomitanSettingsAutomation<boolean>(
|
||||
`
|
||||
(async () => {
|
||||
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
|
||||
${JSON.stringify(fs.readFileSync(normalizedZipPath).toString('base64'))},
|
||||
${JSON.stringify(path.basename(normalizedZipPath))}
|
||||
);
|
||||
return true;
|
||||
})();
|
||||
`,
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import path from 'node:path';
|
||||
import { loadRawConfigStrict } from './config/load';
|
||||
import { resolveConfig } from './config/resolve';
|
||||
import type { MpvLaunchMode, ResolvedConfig } from './types/config';
|
||||
import type { SubminerPluginRuntimeScriptOptConfig } from './shared/subminer-plugin-script-opts';
|
||||
|
||||
export interface ConfiguredWindowsMpvLaunch {
|
||||
executablePath: string;
|
||||
launchMode: MpvLaunchMode;
|
||||
pluginRuntimeConfig: SubminerPluginRuntimeScriptOptConfig;
|
||||
}
|
||||
|
||||
export function buildWindowsMpvPluginRuntimeConfig(
|
||||
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'mpv' | 'texthooker'>,
|
||||
): SubminerPluginRuntimeScriptOptConfig {
|
||||
return {
|
||||
socketPath: config.mpv.socketPath,
|
||||
binaryPath: config.mpv.subminerBinaryPath,
|
||||
backend: config.mpv.backend,
|
||||
autoStart: config.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function readConfiguredWindowsMpvLaunch(configDir: string): ConfiguredWindowsMpvLaunch {
|
||||
const loadResult = loadRawConfigStrict({
|
||||
configDir,
|
||||
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
||||
configFileJson: path.join(configDir, 'config.json'),
|
||||
});
|
||||
const rawConfig = loadResult.ok ? loadResult.config : {};
|
||||
const { resolved } = resolveConfig(rawConfig);
|
||||
|
||||
return {
|
||||
executablePath: resolved.mpv.executablePath,
|
||||
launchMode: resolved.mpv.launchMode,
|
||||
pluginRuntimeConfig: buildWindowsMpvPluginRuntimeConfig(resolved),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG } from './config/definitions';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
@@ -146,7 +151,7 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star
|
||||
]);
|
||||
});
|
||||
|
||||
test('transported AppImage visibility commands should forward through app control', () => {
|
||||
test('transported AppImage visibility commands forward through app control', () => {
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
@@ -156,9 +161,35 @@ test('transported AppImage visibility commands should forward through app contro
|
||||
);
|
||||
});
|
||||
|
||||
test('app control forwarding is only for transported runtime commands', () => {
|
||||
test('direct runtime commands forward through app control', () => {
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(
|
||||
['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer-socket'],
|
||||
{},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--settings'], {}), true);
|
||||
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stop'], {}), true);
|
||||
});
|
||||
|
||||
test('entry-only and internal commands do not forward through app control', () => {
|
||||
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false);
|
||||
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stats-daemon-start'], {}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stats', '--stats-background'], {}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
@@ -175,6 +206,12 @@ test('app control forwarding is only for transported runtime commands', () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--start'], {
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
@@ -269,6 +306,73 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script opts', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-config-'));
|
||||
try {
|
||||
const launch = readConfiguredWindowsMpvLaunch(tempDir);
|
||||
|
||||
assert.equal(launch.executablePath, DEFAULT_CONFIG.mpv.executablePath);
|
||||
assert.equal(launch.launchMode, DEFAULT_CONFIG.mpv.launchMode);
|
||||
assert.deepEqual(launch.pluginRuntimeConfig, {
|
||||
socketPath: DEFAULT_CONFIG.mpv.socketPath,
|
||||
binaryPath: DEFAULT_CONFIG.mpv.subminerBinaryPath,
|
||||
backend: DEFAULT_CONFIG.mpv.backend,
|
||||
autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
||||
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script opts', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-config-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: false,
|
||||
texthooker: {
|
||||
launchAtStartup: true,
|
||||
},
|
||||
mpv: {
|
||||
executablePath: ' C:\\tools\\mpv.exe ',
|
||||
launchMode: 'maximized',
|
||||
socketPath: '\\\\.\\pipe\\custom-subminer-socket',
|
||||
backend: 'windows',
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: 'C:\\SubMiner\\Custom.exe',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const launch = readConfiguredWindowsMpvLaunch(tempDir);
|
||||
|
||||
assert.equal(launch.executablePath, 'C:\\tools\\mpv.exe');
|
||||
assert.equal(launch.launchMode, 'maximized');
|
||||
assert.deepEqual(launch.pluginRuntimeConfig, {
|
||||
socketPath: '\\\\.\\pipe\\custom-subminer-socket',
|
||||
binaryPath: 'C:\\SubMiner\\Custom.exe',
|
||||
backend: 'windows',
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stats-daemon entry helper detects internal daemon commands', () => {
|
||||
assert.equal(
|
||||
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {}),
|
||||
|
||||
@@ -154,10 +154,9 @@ export function shouldForwardStartupArgvViaAppControl(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
if (!hasTransportedStartupArgs(env)) return false;
|
||||
|
||||
const args = parseCliArgs(argv);
|
||||
if (args.help || args.appPing || args.launchMpv) return false;
|
||||
if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false;
|
||||
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
|
||||
|
||||
return hasExplicitCommand(args);
|
||||
|
||||
+45
-65
@@ -1,9 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import { printHelp } from './cli/help';
|
||||
import { loadRawConfigStrict } from './config/load';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
@@ -22,6 +20,7 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
resolvePackagedRuntimePluginPath,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||
|
||||
@@ -150,31 +148,6 @@ function createWindowsRuntimePluginPolicy() {
|
||||
};
|
||||
}
|
||||
|
||||
function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||
executablePath: string;
|
||||
launchMode: 'normal' | 'maximized' | 'fullscreen';
|
||||
} {
|
||||
const loadResult = loadRawConfigStrict({
|
||||
configDir,
|
||||
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
||||
configFileJson: path.join(configDir, 'config.json'),
|
||||
});
|
||||
if (!loadResult.ok) {
|
||||
return {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath:
|
||||
typeof loadResult.config.mpv?.executablePath === 'string'
|
||||
? loadResult.config.mpv.executablePath.trim()
|
||||
: '',
|
||||
launchMode: parseMpvLaunchMode(loadResult.config.mpv?.launchMode) ?? 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
@@ -226,31 +199,22 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: sanitizeBackgroundEnv(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
async function runEntryProcess(): Promise<void> {
|
||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
printHelp(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
printHelp(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
void app.whenReady().then(async () => {
|
||||
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
await app.whenReady();
|
||||
const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath);
|
||||
const result = await launchWindowsMpv(
|
||||
normalizeLaunchMpvTargets(process.argv),
|
||||
@@ -266,23 +230,39 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
configuredMpvLaunch.executablePath,
|
||||
configuredMpvLaunch.launchMode,
|
||||
createWindowsRuntimePluginPolicy(),
|
||||
configuredMpvLaunch.pluginRuntimeConfig,
|
||||
);
|
||||
app.exit(result.ok ? 0 : 1);
|
||||
});
|
||||
} else if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) {
|
||||
void app.whenReady().then(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) {
|
||||
await app.whenReady();
|
||||
const exitCode = await runStatsDaemonControlFromProcess(app.getPath('userData'));
|
||||
app.exit(exitCode);
|
||||
});
|
||||
} else {
|
||||
void forwardStartupArgvViaAppControlIfAvailable()
|
||||
.then((forwarded) => {
|
||||
if (!forwarded) {
|
||||
startMainProcess();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: sanitizeBackgroundEnv(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
startMainProcess();
|
||||
}
|
||||
|
||||
void runEntryProcess().catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
|
||||
+122
-50
@@ -347,6 +347,7 @@ import {
|
||||
syncOverlayWindowLayer,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
showMpvOsdRuntime,
|
||||
startOverlayWindowTracker as startOverlayWindowTrackerCore,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
upsertYomitanDictionarySettings,
|
||||
@@ -517,6 +518,7 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
|
||||
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
|
||||
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
|
||||
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
||||
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
@@ -2177,6 +2179,7 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||
getCollapsibleSectionOpenState: (section) =>
|
||||
getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
||||
now: () => Date.now(),
|
||||
@@ -2184,6 +2187,10 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
|
||||
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
});
|
||||
|
||||
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getConfig: () => {
|
||||
@@ -2460,6 +2467,7 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
|
||||
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
|
||||
return trackedHandle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return findWindowsMpvTargetWindowHandle();
|
||||
} catch {
|
||||
@@ -2467,6 +2475,104 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
|
||||
}
|
||||
}
|
||||
|
||||
function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||
}
|
||||
|
||||
function bindVisibleOverlayOwner(): void {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
const targetSocketPath = appState.mpvSocketPath;
|
||||
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
|
||||
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||
return;
|
||||
}
|
||||
if (targetSocketPath) {
|
||||
return;
|
||||
}
|
||||
const tracker = appState.windowTracker;
|
||||
const mpvResult = tracker
|
||||
? (() => {
|
||||
try {
|
||||
const win32 =
|
||||
require('./window-trackers/win32') as typeof import('./window-trackers/win32');
|
||||
const poll = win32.findMpvWindows();
|
||||
const focused = poll.matches.find((m) => m.isForeground);
|
||||
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
if (!mpvResult) return;
|
||||
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
|
||||
logger.warn('Failed to set overlay owner via koffi');
|
||||
}
|
||||
}
|
||||
|
||||
function releaseVisibleOverlayOwner(): void {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
if (!clearWindowsOverlayOwner(overlayHwnd)) {
|
||||
logger.warn('Failed to clear overlay owner via koffi');
|
||||
}
|
||||
}
|
||||
|
||||
function startOverlayWindowTrackerForCurrentSocket(): void {
|
||||
startOverlayWindowTrackerCore({
|
||||
backendOverride: appState.backendOverride,
|
||||
getMpvSocketPath: () => appState.mpvSocketPath,
|
||||
createWindowTracker: createOverlayWindowTracker,
|
||||
setWindowTracker: (tracker) => {
|
||||
appState.windowTracker = tracker;
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
|
||||
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
refreshCurrentSubtitle: () => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
bindOverlayOwner: () => bindVisibleOverlayOwner(),
|
||||
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
|
||||
});
|
||||
}
|
||||
|
||||
function retargetOverlayWindowTrackerForMpvSocket(
|
||||
nextSocketPath: string,
|
||||
previousSocketPath: string,
|
||||
): void {
|
||||
if (nextSocketPath === previousSocketPath || !appState.overlayRuntimeInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTracker = appState.windowTracker;
|
||||
if (previousTracker) {
|
||||
try {
|
||||
previousTracker.stop();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
|
||||
}
|
||||
}
|
||||
|
||||
releaseVisibleOverlayOwner();
|
||||
appState.windowTracker = null;
|
||||
appState.trackerNotReadyWarningShown = false;
|
||||
lastOverlayWindowGeometry = null;
|
||||
startOverlayWindowTrackerForCurrentSocket();
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||
logger.info(
|
||||
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
@@ -4628,6 +4734,8 @@ const {
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||
!isYoutubePlaybackActiveNow(),
|
||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
@@ -5227,8 +5335,8 @@ function getUpdateService() {
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate),
|
||||
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
|
||||
fetchLatestStableRelease: (channel) =>
|
||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||
updateLauncher: (launcherPath, channel, release) =>
|
||||
@@ -5247,7 +5355,9 @@ function getUpdateService() {
|
||||
{ notificationType: getResolvedConfig().updates.notificationType, version },
|
||||
{
|
||||
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
|
||||
showOsdNotification: (message) => showMpvOsd(message),
|
||||
showOsdNotification: (message) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
log: (message) => logger.warn(message),
|
||||
},
|
||||
),
|
||||
@@ -5865,8 +5975,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
||||
getCharacterDictionarySelection: () =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(),
|
||||
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle),
|
||||
setCharacterDictionarySelection: async (mediaId: number) =>
|
||||
applyCharacterDictionarySelection(
|
||||
{ mediaId },
|
||||
@@ -5925,6 +6035,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
cliCommandContextMainDeps: {
|
||||
appState,
|
||||
setLogLevel: (level) => setLogLevel(level, 'cli'),
|
||||
onMpvSocketPathChanged: (nextSocketPath, previousSocketPath) =>
|
||||
retargetOverlayWindowTrackerForMpvSocket(nextSocketPath, previousSocketPath),
|
||||
texthookerService,
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
|
||||
@@ -6213,7 +6325,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
@@ -6323,52 +6435,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
}
|
||||
registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) =>
|
||||
createOverlayWindowTracker(override, targetMpvSocketPath),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
updateVisibleOverlayBounds(geometry),
|
||||
bindOverlayOwner: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
|
||||
if (
|
||||
targetWindowHwnd !== null &&
|
||||
bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tracker = appState.windowTracker;
|
||||
const mpvResult = tracker
|
||||
? (() => {
|
||||
try {
|
||||
const win32 =
|
||||
require('./window-trackers/win32') as typeof import('./window-trackers/win32');
|
||||
const poll = win32.findMpvWindows();
|
||||
const focused = poll.matches.find((m) => m.isForeground);
|
||||
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
if (!mpvResult) return;
|
||||
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
|
||||
logger.warn('Failed to set overlay owner via koffi');
|
||||
}
|
||||
},
|
||||
releaseOverlayOwner: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
if (!clearWindowsOverlayOwner(overlayHwnd)) {
|
||||
logger.warn('Failed to clear overlay owner via koffi');
|
||||
}
|
||||
},
|
||||
bindOverlayOwner: () => bindVisibleOverlayOwner(),
|
||||
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
showDesktopNotification,
|
||||
|
||||
@@ -195,22 +195,45 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
|
||||
assert.equal(nameDiv.tag, 'div');
|
||||
assert.equal(nameDiv.content, 'アレクシア・ミドガル');
|
||||
|
||||
const secondaryNameDiv = children[1] as { tag: string; content: string };
|
||||
assert.equal(secondaryNameDiv.tag, 'div');
|
||||
assert.equal(secondaryNameDiv.content, 'Alexia Midgar');
|
||||
assert.equal(
|
||||
children.some((child) => (child as { content?: unknown }).content === 'Alexia Midgar'),
|
||||
false,
|
||||
);
|
||||
|
||||
const imageWrap = children[2] as { tag: string; content: Record<string, unknown> };
|
||||
const imageWrap = children.find((child) => {
|
||||
const content = (child as { content?: unknown }).content;
|
||||
return (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
!Array.isArray(content) &&
|
||||
(content as { path?: unknown }).path === 'img/m130298-c123.png'
|
||||
);
|
||||
}) as { tag: string; content: Record<string, unknown> } | undefined;
|
||||
assert.ok(imageWrap);
|
||||
assert.equal(imageWrap.tag, 'div');
|
||||
const image = imageWrap.content as Record<string, unknown>;
|
||||
assert.equal(image.tag, 'img');
|
||||
assert.equal(image.path, 'img/m130298-c123.png');
|
||||
assert.equal(image.sizeUnits, 'em');
|
||||
|
||||
const sourceDiv = children[3] as { tag: string; content: string };
|
||||
const sourceDiv = children.find((child) => {
|
||||
const content = (child as { content?: unknown }).content;
|
||||
return typeof content === 'string' && content.includes('The Eminence in Shadow');
|
||||
}) as { tag: string; content: string } | undefined;
|
||||
assert.ok(sourceDiv);
|
||||
assert.equal(sourceDiv.tag, 'div');
|
||||
assert.ok(sourceDiv.content.includes('The Eminence in Shadow'));
|
||||
|
||||
const roleBadgeDiv = children[4] as { tag: string; content: Record<string, unknown> };
|
||||
const roleBadgeDiv = children.find((child) => {
|
||||
const content = (child as { content?: unknown }).content;
|
||||
return (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
!Array.isArray(content) &&
|
||||
(content as { content?: unknown }).content === 'Main Character'
|
||||
);
|
||||
}) as { tag: string; content: Record<string, unknown> } | undefined;
|
||||
assert.ok(roleBadgeDiv);
|
||||
assert.equal(roleBadgeDiv.tag, 'div');
|
||||
const badge = roleBadgeDiv.content as { tag: string; content: string };
|
||||
assert.equal(badge.tag, 'span');
|
||||
@@ -1882,9 +1905,9 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
|
||||
'[dictionary] snapshot miss for AniList 130298, fetching characters',
|
||||
'[dictionary] downloaded AniList character page 1 for AniList 130298',
|
||||
'[dictionary] downloading 1 images for AniList 130298',
|
||||
'[dictionary] stored snapshot for AniList 130298: 32 terms',
|
||||
'[dictionary] stored snapshot for AniList 130298: 16 terms',
|
||||
'[dictionary] building ZIP for AniList 130298',
|
||||
'[dictionary] generated AniList 130298: 32 terms -> ' +
|
||||
'[dictionary] generated AniList 130298: 16 terms -> ' +
|
||||
path.join(userDataPath, 'character-dictionaries', 'anilist-130298.zip'),
|
||||
]);
|
||||
} finally {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './character-dictionary-runtime/manual-selection';
|
||||
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryBuildResult,
|
||||
@@ -151,6 +152,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
getManualSelectionSnapshot: (
|
||||
targetPath?: string,
|
||||
searchTitle?: string,
|
||||
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
||||
setManualSelection: (request: {
|
||||
targetPath?: string;
|
||||
@@ -168,6 +170,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
|
||||
const shouldRefreshCachedSnapshot = (snapshot: CharacterDictionarySnapshot): boolean => {
|
||||
if (deps.getNameMatchImagesEnabled?.() !== true) {
|
||||
return false;
|
||||
}
|
||||
return !snapshotHasCharacterNameImages(snapshot);
|
||||
};
|
||||
|
||||
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
||||
let hasAniListRequest = false;
|
||||
return async (): Promise<void> => {
|
||||
@@ -205,12 +214,19 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
mediaTitle: guessInput.mediaTitle,
|
||||
guess: guessed,
|
||||
}),
|
||||
unscopedSeriesKey: buildCharacterDictionarySeriesKey({
|
||||
mediaPath: null,
|
||||
mediaTitle: guessInput.mediaTitle,
|
||||
guess: guessed,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const findCachedSnapshotForSeriesKey = (
|
||||
seriesKey: string,
|
||||
fallbackSeriesKey?: string,
|
||||
): CharacterDictionarySnapshot | null => {
|
||||
const acceptedKeys = new Set([seriesKey, fallbackSeriesKey].filter(Boolean));
|
||||
return (
|
||||
readCachedSnapshots(outputDir).find((snapshot) => {
|
||||
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
||||
@@ -223,7 +239,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
source: 'fallback',
|
||||
},
|
||||
});
|
||||
return snapshotSeriesKey === seriesKey;
|
||||
return acceptedKeys.has(snapshotSeriesKey);
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
@@ -233,7 +249,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<ResolvedAniListMedia> => {
|
||||
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const { guessed, seriesKey, unscopedSeriesKey } = await guessCurrentMedia(targetPath);
|
||||
deps.logInfo?.(
|
||||
`[dictionary] current anime guess: ${guessed.title.trim()}${
|
||||
typeof guessed.episode === 'number' && guessed.episode > 0
|
||||
@@ -267,7 +283,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
}
|
||||
}
|
||||
|
||||
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
|
||||
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey, unscopedSeriesKey);
|
||||
if (cachedSnapshot) {
|
||||
writeCachedMediaResolution(outputDir, {
|
||||
seriesKey,
|
||||
@@ -301,7 +317,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
): Promise<CharacterDictionarySnapshotResult> => {
|
||||
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
||||
const cachedSnapshot = readSnapshot(snapshotPath);
|
||||
if (cachedSnapshot) {
|
||||
if (cachedSnapshot && !shouldRefreshCachedSnapshot(cachedSnapshot)) {
|
||||
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
|
||||
return {
|
||||
mediaId: cachedSnapshot.mediaId,
|
||||
@@ -311,6 +327,11 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
updatedAt: cachedSnapshot.updatedAt,
|
||||
};
|
||||
}
|
||||
if (cachedSnapshot) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] snapshot stale for AniList ${mediaId}: missing cached character images`,
|
||||
);
|
||||
}
|
||||
|
||||
progress?.onGenerating?.({
|
||||
mediaId,
|
||||
@@ -455,28 +476,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
entryCount,
|
||||
};
|
||||
},
|
||||
getManualSelectionSnapshot: async (targetPath?: string) => {
|
||||
getManualSelectionSnapshot: async (targetPath?: string, searchTitle?: string) => {
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const [candidates, override] = await Promise.all([
|
||||
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
|
||||
const normalizedSearchTitle = searchTitle?.trim();
|
||||
const shouldUseExplicitSearch = searchTitle !== undefined;
|
||||
const candidateSearchTitle = shouldUseExplicitSearch ? normalizedSearchTitle : guessed.title;
|
||||
const candidates = candidateSearchTitle
|
||||
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
|
||||
: [];
|
||||
const [override, current] = await Promise.all([
|
||||
manualSelectionStore.getOverride(seriesKey),
|
||||
shouldUseExplicitSearch
|
||||
? Promise.resolve(null)
|
||||
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||
.then(
|
||||
(entry): AniListMediaCandidate => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
episodes:
|
||||
candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
||||
}),
|
||||
)
|
||||
.catch(() => null),
|
||||
]);
|
||||
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);
|
||||
const overrideCandidate = override
|
||||
? candidates.find((candidate) => candidate.id === override.mediaId)
|
||||
: null;
|
||||
return {
|
||||
seriesKey,
|
||||
guessTitle: guessed.title,
|
||||
current,
|
||||
override: override
|
||||
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
|
||||
? {
|
||||
id: override.mediaId,
|
||||
title: override.mediaTitle,
|
||||
episodes: overrideCandidate?.episodes ?? null,
|
||||
}
|
||||
: null,
|
||||
candidates,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyCollapsibleOpenStatesToTermEntries } from './build';
|
||||
import type { CharacterDictionaryTermEntry } from './types';
|
||||
import { applyCollapsibleOpenStatesToTermEntries, buildSnapshotFromCharacters } from './build';
|
||||
import type { CharacterDictionaryTermEntry, CharacterRecord } from './types';
|
||||
|
||||
test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open states', () => {
|
||||
const termEntries: CharacterDictionaryTermEntry[] = [
|
||||
@@ -56,3 +56,66 @@ test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open
|
||||
assert.equal(glossaryEntry.content.content[0]?.open, true);
|
||||
assert.equal(glossaryEntry.content.content[1]?.open, false);
|
||||
});
|
||||
|
||||
test('buildSnapshotFromCharacters shows Japanese aliases without adding romanized names as lookup entries', () => {
|
||||
const character: CharacterRecord = {
|
||||
id: 1,
|
||||
role: 'main',
|
||||
firstNameHint: '',
|
||||
fullName: 'Aqua',
|
||||
lastNameHint: '',
|
||||
nativeName: 'アクア',
|
||||
alternativeNames: ['阿久亜'],
|
||||
bloodType: '',
|
||||
birthday: null,
|
||||
description: '',
|
||||
imageUrl: null,
|
||||
age: '',
|
||||
sex: '',
|
||||
voiceActors: [],
|
||||
};
|
||||
|
||||
const snapshot = buildSnapshotFromCharacters(
|
||||
100,
|
||||
'KonoSuba',
|
||||
[character],
|
||||
new Map(),
|
||||
new Map(),
|
||||
1_700_000_000_000,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const aquaEntry = snapshot.termEntries.find(([term]) => term === 'アクア');
|
||||
assert.ok(aquaEntry);
|
||||
const glossaryEntry = aquaEntry[5][0] as {
|
||||
content: {
|
||||
content: Array<{ content?: unknown }>;
|
||||
};
|
||||
};
|
||||
const wholeGlossary = JSON.stringify(glossaryEntry);
|
||||
|
||||
const knownNames = glossaryEntry.content.content.find((node) => {
|
||||
const content = node.content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.some(
|
||||
(child) =>
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
(child as { content?: unknown }).content === 'Known names',
|
||||
)
|
||||
);
|
||||
}) as { content: Array<{ content?: unknown }> } | undefined;
|
||||
assert.ok(knownNames, 'expected a Known names block in the character glossary');
|
||||
const knownNameItems = JSON.stringify(knownNames.content);
|
||||
const terms = snapshot.termEntries.map(([term]) => term);
|
||||
|
||||
assert.match(knownNameItems, /アクア/);
|
||||
assert.match(knownNameItems, /阿久亜/);
|
||||
assert.doesNotMatch(wholeGlossary, /Aqua/);
|
||||
assert.doesNotMatch(knownNameItems, /Aqua/);
|
||||
assert.doesNotMatch(knownNameItems, /アクア様/);
|
||||
assert.equal(terms.includes('Aqua'), false);
|
||||
assert.equal(terms.includes('アクア'), true);
|
||||
assert.equal(terms.includes('阿久亜'), true);
|
||||
});
|
||||
|
||||
@@ -52,3 +52,18 @@ test('readSnapshot ignores snapshots written with an older format version', () =
|
||||
|
||||
assert.equal(readSnapshot(snapshotPath), null);
|
||||
});
|
||||
|
||||
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshotPath = getSnapshotPath(outputDir, 130298);
|
||||
const staleSnapshot = {
|
||||
...createSnapshot(),
|
||||
formatVersion: 15,
|
||||
termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']],
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
|
||||
|
||||
assert.equal(readSnapshot(snapshotPath), null);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 15;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16;
|
||||
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||
|
||||
export const HONORIFIC_SUFFIXES = [
|
||||
|
||||
@@ -191,11 +191,51 @@ function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
||||
return 'side';
|
||||
}
|
||||
|
||||
function inferImageExt(contentType: string | null): string {
|
||||
function inferImageExtFromBytes(bytes: Buffer): string | null {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF89a') {
|
||||
return 'gif';
|
||||
}
|
||||
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF87a') {
|
||||
return 'gif';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'avif'
|
||||
) {
|
||||
return 'avif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferImageExt(contentType: string | null, bytes: Buffer): string {
|
||||
const extFromBytes = inferImageExtFromBytes(bytes);
|
||||
if (extFromBytes) return extFromBytes;
|
||||
|
||||
const normalized = (contentType || '').toLowerCase();
|
||||
if (normalized.includes('png')) return 'png';
|
||||
if (normalized.includes('gif')) return 'gif';
|
||||
if (normalized.includes('webp')) return 'webp';
|
||||
if (normalized.includes('avif')) return 'avif';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
@@ -462,7 +502,7 @@ export async function downloadCharacterImage(
|
||||
if (!response.ok) return null;
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length === 0) return null;
|
||||
const ext = inferImageExt(response.headers.get('content-type'));
|
||||
const ext = inferImageExt(response.headers.get('content-type'), bytes);
|
||||
return {
|
||||
filename: `c${charId}.${ext}`,
|
||||
ext,
|
||||
|
||||
@@ -117,20 +117,44 @@ function buildVoicedByContent(
|
||||
return { tag: 'ul', style: { marginTop: '0.15em' }, content: items };
|
||||
}
|
||||
|
||||
function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | null {
|
||||
const visibleTerms = [...new Set(nameTerms.map((term) => term.trim()).filter(Boolean))];
|
||||
if (visibleTerms.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: 'div',
|
||||
style: { fontSize: '0.85em', marginBottom: '0.25em' },
|
||||
content: [
|
||||
{
|
||||
tag: 'div',
|
||||
style: { fontWeight: 'bold', color: '#d0d0d0', marginBottom: '0.1em' },
|
||||
content: 'Known names',
|
||||
},
|
||||
{
|
||||
tag: 'ul',
|
||||
style: { marginTop: '0', marginBottom: '0', paddingLeft: '1.2em' },
|
||||
content: visibleTerms.map((term) => ({
|
||||
tag: 'li',
|
||||
content: term,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefinitionGlossary(
|
||||
character: CharacterRecord,
|
||||
mediaTitle: string,
|
||||
imagePath: string | null,
|
||||
vaImagePaths: Map<number, string>,
|
||||
nameTerms: string[],
|
||||
getCollapsibleSectionOpenState: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean,
|
||||
): CharacterDictionaryGlossaryEntry[] {
|
||||
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
|
||||
const secondaryName =
|
||||
character.nativeName && character.fullName && character.fullName !== character.nativeName
|
||||
? character.fullName
|
||||
: null;
|
||||
const { fields, text: descriptionText } = parseCharacterDescription(character.description);
|
||||
|
||||
const content: Array<string | Record<string, unknown>> = [
|
||||
@@ -141,12 +165,9 @@ export function createDefinitionGlossary(
|
||||
},
|
||||
];
|
||||
|
||||
if (secondaryName) {
|
||||
content.push({
|
||||
tag: 'div',
|
||||
style: { fontSize: '0.85em', fontStyle: 'italic', color: '#b0b0b0', marginBottom: '0.2em' },
|
||||
content: secondaryName,
|
||||
});
|
||||
const knownNamesBlock = buildKnownNamesBlock(nameTerms);
|
||||
if (knownNamesBlock) {
|
||||
content.push(knownNamesBlock);
|
||||
}
|
||||
|
||||
if (imagePath) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup';
|
||||
import type { CharacterDictionarySnapshot } from './types';
|
||||
|
||||
const PNG_1X1_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-image-lookup-'));
|
||||
}
|
||||
|
||||
test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshot: CharacterDictionarySnapshot = {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [
|
||||
[
|
||||
'アレクシア',
|
||||
'あれくしあ',
|
||||
'name primary',
|
||||
'',
|
||||
75,
|
||||
[
|
||||
{
|
||||
type: 'structured-content',
|
||||
content: {
|
||||
tag: 'div',
|
||||
content: [
|
||||
{ tag: 'div', content: 'アレクシア・ミドガル' },
|
||||
{
|
||||
tag: 'div',
|
||||
content: {
|
||||
tag: 'img',
|
||||
path: 'img/m130298-c123.png',
|
||||
alt: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'details',
|
||||
content: [
|
||||
{ tag: 'summary', content: 'Voiced by' },
|
||||
{
|
||||
tag: 'div',
|
||||
content: {
|
||||
tag: 'img',
|
||||
path: 'img/m130298-va456.png',
|
||||
alt: 'VA',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
0,
|
||||
'',
|
||||
],
|
||||
],
|
||||
images: [
|
||||
{ path: 'img/m130298-c123.png', dataBase64: 'AAAA' },
|
||||
{ path: 'img/m130298-va456.png', dataBase64: 'BBBB' },
|
||||
],
|
||||
};
|
||||
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||
|
||||
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
|
||||
assert.deepEqual(index.get('アレクシア'), {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アレクシア・ミドガル',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshot: CharacterDictionarySnapshot = {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [
|
||||
[
|
||||
'アレクシア',
|
||||
'あれくしあ',
|
||||
'name primary',
|
||||
'',
|
||||
75,
|
||||
[
|
||||
{
|
||||
type: 'structured-content',
|
||||
content: {
|
||||
tag: 'img',
|
||||
path: 'img/m130298-c123.jpg',
|
||||
alt: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
],
|
||||
0,
|
||||
'',
|
||||
],
|
||||
],
|
||||
images: [{ path: 'img/m130298-c123.jpg', dataBase64: PNG_1X1_BASE64 }],
|
||||
};
|
||||
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||
|
||||
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
|
||||
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { CharacterNameImage } from '../../types';
|
||||
import { readCachedSnapshots } from './cache';
|
||||
import type {
|
||||
CharacterDictionaryGlossaryEntry,
|
||||
CharacterDictionarySnapshot,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionaryTermEntry,
|
||||
} from './types';
|
||||
|
||||
const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i;
|
||||
|
||||
type StructuredContentNode = {
|
||||
tag?: unknown;
|
||||
path?: unknown;
|
||||
alt?: unknown;
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function normalizeLookupTerm(term: string): string {
|
||||
return term.trim();
|
||||
}
|
||||
|
||||
function getSnapshotsDir(outputDir: string): string {
|
||||
return path.join(outputDir, 'snapshots');
|
||||
}
|
||||
|
||||
function getImageMimeType(imagePath: string, dataBase64: string): string {
|
||||
const signature = Buffer.from(dataBase64.slice(0, 64), 'base64');
|
||||
if (
|
||||
signature.length >= 8 &&
|
||||
signature[0] === 0x89 &&
|
||||
signature[1] === 0x50 &&
|
||||
signature[2] === 0x4e &&
|
||||
signature[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (
|
||||
signature.length >= 12 &&
|
||||
signature.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
signature.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (
|
||||
signature.length >= 6 &&
|
||||
(signature.subarray(0, 6).toString('ascii') === 'GIF89a' ||
|
||||
signature.subarray(0, 6).toString('ascii') === 'GIF87a')
|
||||
) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
signature.length >= 12 &&
|
||||
signature.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||
signature.subarray(8, 12).toString('ascii') === 'avif'
|
||||
) {
|
||||
return 'image/avif';
|
||||
}
|
||||
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.avif') return 'image/avif';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function buildImageByPath(
|
||||
images: ReadonlyArray<CharacterDictionarySnapshotImage>,
|
||||
): Map<string, CharacterDictionarySnapshotImage> {
|
||||
const imageByPath = new Map<string, CharacterDictionarySnapshotImage>();
|
||||
for (const image of images) {
|
||||
if (image.path && image.dataBase64) {
|
||||
imageByPath.set(image.path, image);
|
||||
}
|
||||
}
|
||||
return imageByPath;
|
||||
}
|
||||
|
||||
function findCharacterImageNode(value: unknown): StructuredContentNode | null {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findCharacterImageNode(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = value as StructuredContentNode;
|
||||
if (
|
||||
node.tag === 'img' &&
|
||||
typeof node.path === 'string' &&
|
||||
CHARACTER_IMAGE_PATH_PATTERN.test(node.path)
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return findCharacterImageNode(node.content);
|
||||
}
|
||||
|
||||
function findCharacterImageNodeInGlossary(
|
||||
glossary: ReadonlyArray<CharacterDictionaryGlossaryEntry>,
|
||||
): StructuredContentNode | null {
|
||||
for (const entry of glossary) {
|
||||
const found = findCharacterImageNode(entry);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCharacterNameImage(
|
||||
entry: CharacterDictionaryTermEntry,
|
||||
imageByPath: ReadonlyMap<string, CharacterDictionarySnapshotImage>,
|
||||
): CharacterNameImage | null {
|
||||
const term = normalizeLookupTerm(entry[0]);
|
||||
if (!term) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageNode = findCharacterImageNodeInGlossary(entry[5]);
|
||||
const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : '';
|
||||
const image = imageByPath.get(imagePath);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawAlt =
|
||||
typeof imageNode?.alt === 'string'
|
||||
? imageNode.alt
|
||||
: typeof imageNode?.title === 'string'
|
||||
? imageNode.title
|
||||
: term;
|
||||
const alt = rawAlt.trim() || term;
|
||||
return {
|
||||
src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`,
|
||||
alt,
|
||||
};
|
||||
}
|
||||
|
||||
function appendSnapshotImages(
|
||||
index: Map<string, CharacterNameImage>,
|
||||
snapshot: CharacterDictionarySnapshot,
|
||||
): void {
|
||||
const imageByPath = buildImageByPath(snapshot.images);
|
||||
for (const entry of snapshot.termEntries) {
|
||||
const term = normalizeLookupTerm(entry[0]);
|
||||
if (!term || index.has(term)) {
|
||||
continue;
|
||||
}
|
||||
const image = createCharacterNameImage(entry, imageByPath);
|
||||
if (image) {
|
||||
index.set(term, image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean {
|
||||
const imageByPath = buildImageByPath(snapshot.images);
|
||||
return snapshot.termEntries.some(
|
||||
(entry) => createCharacterNameImage(entry, imageByPath) !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function getSnapshotDirectorySignature(outputDir: string): string {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(snapshotPath);
|
||||
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
|
||||
} catch {
|
||||
// Ignore files that disappear during refresh; next lookup will rebuild.
|
||||
}
|
||||
}
|
||||
return parts.sort().join('|');
|
||||
}
|
||||
|
||||
export function buildCharacterNameImageIndexFromSnapshots(
|
||||
outputDir: string,
|
||||
): Map<string, CharacterNameImage> {
|
||||
const index = new Map<string, CharacterNameImage>();
|
||||
for (const snapshot of readCachedSnapshots(outputDir)) {
|
||||
appendSnapshotImages(index, snapshot);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryImageLookup(deps: {
|
||||
userDataPath?: string;
|
||||
outputDir?: string;
|
||||
}): {
|
||||
get: (term: string) => CharacterNameImage | null;
|
||||
invalidate: () => void;
|
||||
} {
|
||||
const outputDir =
|
||||
deps.outputDir ??
|
||||
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
||||
let signature: string | null = null;
|
||||
let index = new Map<string, CharacterNameImage>();
|
||||
|
||||
function refreshIfNeeded(): void {
|
||||
if (!outputDir) {
|
||||
index = new Map<string, CharacterNameImage>();
|
||||
signature = '';
|
||||
return;
|
||||
}
|
||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||
if (nextSignature === signature) {
|
||||
return;
|
||||
}
|
||||
signature = nextSignature;
|
||||
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
}
|
||||
|
||||
return {
|
||||
get(term: string): CharacterNameImage | null {
|
||||
const normalizedTerm = normalizeLookupTerm(term);
|
||||
if (!normalizedTerm) {
|
||||
return null;
|
||||
}
|
||||
refreshIfNeeded();
|
||||
return index.get(normalizedTerm) ?? null;
|
||||
},
|
||||
invalidate(): void {
|
||||
signature = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { buildCharacterDictionarySeriesKey } from './manual-selection';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
test('getManualSelectionSnapshot waits for explicit search text before fetching candidates', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const searchTerms: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
variables?: { search?: string };
|
||||
};
|
||||
searchTerms.push(String(body.variables?.search ?? ''));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 154587,
|
||||
episodes: 28,
|
||||
title: {
|
||||
romaji: 'Sousou no Frieren',
|
||||
english: 'Frieren: Beyond Journey’s End',
|
||||
native: '葬送のフリーレン',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
|
||||
getCurrentMediaTitle: () => '[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const initial = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||
assert.equal(initial.guessTitle, 'Kage no Jitsuryokusha ni Naritakute!');
|
||||
assert.deepEqual(initial.candidates, []);
|
||||
assert.deepEqual(searchTerms, []);
|
||||
|
||||
const searched = await runtime.getManualSelectionSnapshot(undefined, 'Frieren');
|
||||
assert.deepEqual(searchTerms, ['Frieren']);
|
||||
assert.deepEqual(searched.candidates, [
|
||||
{ id: 154587, title: 'Frieren: Beyond Journey’s End', episodes: 28 },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getManualSelectionSnapshot hydrates override episode count from searched candidates', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const overrideSeriesKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/tmp/KonoSuba - 01.mkv',
|
||||
mediaTitle: 'KonoSuba - 01.mkv',
|
||||
guess: {
|
||||
title: "KonoSuba - God's blessing on this wonderful world!",
|
||||
year: 2016,
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(overrideDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overrideDir, 'anilist-overrides.json'),
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: overrideSeriesKey,
|
||||
mediaId: 21202,
|
||||
mediaTitle: "KONOSUBA -God's blessing on this wonderful world!",
|
||||
staleMediaIds: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (_input: string | URL | Request) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 21202,
|
||||
episodes: 10,
|
||||
title: {
|
||||
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||
english: "KONOSUBA -God's blessing on this wonderful world!",
|
||||
native: 'この素晴らしい世界に祝福を!',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/KonoSuba - 01.mkv',
|
||||
getCurrentMediaTitle: () => 'KonoSuba - 01.mkv',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: "KonoSuba - God's blessing on this wonderful world!",
|
||||
year: 2016,
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getManualSelectionSnapshot(undefined, 'KonoSuba');
|
||||
|
||||
assert.deepEqual(snapshot.override, {
|
||||
id: 21202,
|
||||
title: "KONOSUBA -God's blessing on this wonderful world!",
|
||||
episodes: 10,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -10,15 +10,17 @@ import {
|
||||
} 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';
|
||||
'/anime/ReZERO/Season 1/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';
|
||||
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||
const REZERO_S2_EP1 =
|
||||
'/anime/ReZERO/Season 2/Re - ZERO, Starting Life in Another World (2016) - S02E01 - Each Ones Promise [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', () => {
|
||||
test('buildCharacterDictionarySeriesKey scopes guessit title and year by media directory', () => {
|
||||
const key = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
@@ -32,10 +34,10 @@ test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, a
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
|
||||
assert.equal(key, 'anime-rezero-season-1--re-zero-starting-life-in-another-world-2016');
|
||||
});
|
||||
|
||||
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
|
||||
test('manual selection store persists overrides and matches later episodes in the same directory', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const firstKey = buildCharacterDictionarySeriesKey({
|
||||
@@ -79,3 +81,131 @@ test('manual selection store persists overrides and matches later episodes in th
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
|
||||
test('manual selection store resolves legacy unscoped override keys', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(overrideDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overrideDir, 'anilist-overrides.json'),
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const scopedKey = 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',
|
||||
},
|
||||
});
|
||||
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
|
||||
assert.deepEqual(await store.getOverride(scopedKey), {
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
|
||||
test('manual selection store prefers exact scoped override over legacy fallback', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(overrideDir, { recursive: true });
|
||||
const scopedKey = 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',
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(overrideDir, 'anilist-overrides.json'),
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
mediaId: 10607,
|
||||
mediaTitle: 'Legacy Re:ZERO',
|
||||
staleMediaIds: [],
|
||||
},
|
||||
{
|
||||
seriesKey: scopedKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
|
||||
assert.deepEqual(await store.getOverride(scopedKey), {
|
||||
seriesKey: scopedKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
|
||||
test('manual selection store keeps overrides separate for different season directories', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const firstSeasonKey = 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: firstSeasonKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [],
|
||||
});
|
||||
|
||||
const secondSeasonKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_S2_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 2,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(secondSeasonKey, firstSeasonKey);
|
||||
assert.equal(await store.getOverride(secondSeasonKey), null);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,29 @@ function normalizeSeriesKeyPart(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getMediaDirectoryKey(mediaPath: string | null): string {
|
||||
const rawPath = mediaPath?.trim();
|
||||
if (!rawPath) return '';
|
||||
|
||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(rawPath) || rawPath.startsWith('file:')) {
|
||||
try {
|
||||
const url = new URL(rawPath);
|
||||
const directoryPath = path.posix.dirname(
|
||||
decodeURIComponent(url.pathname).replace(/\\/g, '/'),
|
||||
);
|
||||
const scopedPath = `${url.hostname}${directoryPath === '/' ? '' : directoryPath}`;
|
||||
return normalizeSeriesKeyPart(scopedPath);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPath = rawPath.replace(/\\/g, '/');
|
||||
const directoryPath = path.posix.dirname(normalizedPath);
|
||||
if (!directoryPath || directoryPath === '.') return '';
|
||||
return normalizeSeriesKeyPart(directoryPath);
|
||||
}
|
||||
|
||||
function dedupeNumbers(values: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const result: number[] = [];
|
||||
@@ -78,6 +101,12 @@ function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSe
|
||||
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
|
||||
const scopedSeparatorIndex = seriesKey.indexOf('--');
|
||||
if (scopedSeparatorIndex < 0) return [seriesKey];
|
||||
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
|
||||
}
|
||||
|
||||
export function buildCharacterDictionarySeriesKey(input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
@@ -94,7 +123,9 @@ export function buildCharacterDictionarySeriesKey(input: {
|
||||
.replace(/\bepisode\s+\d+\b/gi, ' ')
|
||||
.trim();
|
||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||
return input.guess?.year ? `${base}-${input.guess.year}` : base;
|
||||
const directoryKey = getMediaDirectoryKey(input.mediaPath);
|
||||
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
|
||||
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||
@@ -102,7 +133,13 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
||||
|
||||
return {
|
||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
|
||||
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
||||
const overrides = readOverrides(filePath);
|
||||
for (const candidate of candidates) {
|
||||
const match = overrides.find((entry) => entry.seriesKey === candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
||||
const normalized = normalizeOverride(selection);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import type { CharacterDictionarySnapshot } from './types';
|
||||
|
||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
|
||||
return {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']],
|
||||
images: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
fetchUrls.push(url);
|
||||
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
};
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: {
|
||||
english: 'The Eminence in Shadow',
|
||||
},
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: {
|
||||
large: 'https://cdn.example.com/character-123.png',
|
||||
medium: null,
|
||||
},
|
||||
name: {
|
||||
full: 'Alexia Midgar',
|
||||
native: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url === 'https://cdn.example.com/character-123.png') {
|
||||
return new Response(PNG_1X1, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.ok(fetchUrls.includes(GRAPHQL_URL));
|
||||
assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png'));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,12 @@ import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../../type
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import { createDefinitionGlossary } from './glossary';
|
||||
import { generateNameReadings, splitJapaneseName } from './name-reading';
|
||||
import { buildNameTerms, buildReadingForTerm, buildTermEntry } from './term-building';
|
||||
import {
|
||||
buildNameTerms,
|
||||
buildReadingForTerm,
|
||||
buildTermEntry,
|
||||
buildVisibleNameTerms,
|
||||
} from './term-building';
|
||||
import type {
|
||||
CharacterDictionaryGlossaryEntry,
|
||||
CharacterDictionarySnapshot,
|
||||
@@ -40,14 +45,15 @@ export function buildSnapshotFromCharacters(
|
||||
const vaImg = imagesByVaId.get(va.id);
|
||||
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
|
||||
}
|
||||
const candidateTerms = buildNameTerms(character);
|
||||
const glossary = createDefinitionGlossary(
|
||||
character,
|
||||
mediaTitle,
|
||||
imagePath,
|
||||
vaImagePaths,
|
||||
buildVisibleNameTerms(candidateTerms),
|
||||
getCollapsibleSectionOpenState,
|
||||
);
|
||||
const candidateTerms = buildNameTerms(character);
|
||||
const nameParts = splitJapaneseName(
|
||||
character.nativeName,
|
||||
character.firstNameHint,
|
||||
|
||||
@@ -41,25 +41,27 @@ function expandRawNameVariants(rawName: string): string[] {
|
||||
|
||||
export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
const base = new Set<string>();
|
||||
const romanizedBase = new Set<string>();
|
||||
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
|
||||
for (const rawName of rawNames) {
|
||||
for (const name of expandRawNameVariants(rawName)) {
|
||||
base.add(name);
|
||||
const target = isRomanizedName(name) ? romanizedBase : base;
|
||||
target.add(name);
|
||||
|
||||
const compact = name.replace(/[\s\u3000]+/g, '');
|
||||
if (compact && compact !== name) {
|
||||
base.add(compact);
|
||||
target.add(compact);
|
||||
}
|
||||
|
||||
const noMiddleDots = compact.replace(/[・・·•]/g, '');
|
||||
if (noMiddleDots && noMiddleDots !== compact) {
|
||||
base.add(noMiddleDots);
|
||||
target.add(noMiddleDots);
|
||||
}
|
||||
|
||||
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
||||
if (split.length === 2) {
|
||||
base.add(split[0]!);
|
||||
base.add(split[1]!);
|
||||
target.add(split[0]!);
|
||||
target.add(split[1]!);
|
||||
}
|
||||
|
||||
const splitByMiddleDot = name
|
||||
@@ -68,12 +70,16 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
.filter((part) => part.length > 0);
|
||||
if (splitByMiddleDot.length >= 2) {
|
||||
for (const part of splitByMiddleDot) {
|
||||
base.add(part);
|
||||
target.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
|
||||
base.add(alias);
|
||||
}
|
||||
|
||||
const nativeParts = splitJapaneseName(
|
||||
character.nativeName,
|
||||
character.firstNameHint,
|
||||
@@ -94,16 +100,24 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
|
||||
withHonorifics.add(alias);
|
||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||
withHonorifics.add(`${alias}${suffix.term}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
||||
}
|
||||
|
||||
export function buildVisibleNameTerms(nameTerms: string[]): string[] {
|
||||
const allTerms = new Set(nameTerms);
|
||||
return nameTerms.filter((term) => {
|
||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||
if (!term.endsWith(suffix.term) || term.length <= suffix.term.length) {
|
||||
continue;
|
||||
}
|
||||
if (allTerms.has(term.slice(0, -suffix.term.length))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildReadingForTerm(
|
||||
term: string,
|
||||
character: CharacterRecord,
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface CharacterDictionaryRuntimeDeps {
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
getNameMatchImagesEnabled?: () => boolean;
|
||||
getCollapsibleSectionOpenState?: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean;
|
||||
|
||||
@@ -22,31 +22,41 @@ test('auto sync notifications send osd updates for progress phases', () => {
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('checking', 'checking'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
@@ -65,28 +75,85 @@ test('auto sync notifications never send desktop notifications', () => {
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
|
||||
});
|
||||
|
||||
test('auto sync notifications fall back to desktop for long progress when osd is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
return false;
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
return false;
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:generating', 'desktop:SubMiner:generating', 'osd:ready']);
|
||||
});
|
||||
|
||||
test('auto sync notifications fall back to desktop when startup sequencer cannot show osd', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
startupOsdSequencer: {
|
||||
notifyCharacterDictionaryStatus: (event) => {
|
||||
calls.push(`sequencer:${event.phase}:${event.message}`);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'sequencer:importing:importing',
|
||||
'desktop:SubMiner:importing',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5,10 +5,12 @@ export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAu
|
||||
|
||||
export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
||||
showOsd: (message: string) => void;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
startupOsdSequencer?: {
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
notifyCharacterDictionaryStatus: (
|
||||
event: StartupOsdSequencerCharacterDictionaryEvent,
|
||||
) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +18,16 @@ function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): bo
|
||||
return type !== 'none';
|
||||
}
|
||||
|
||||
function shouldFallbackToDesktop(
|
||||
type: 'osd' | 'system' | 'both' | 'none' | undefined,
|
||||
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
||||
): boolean {
|
||||
return (
|
||||
(type === 'system' || type === 'both') &&
|
||||
(phase === 'generating' || phase === 'building' || phase === 'importing')
|
||||
);
|
||||
}
|
||||
|
||||
export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
event: CharacterDictionaryAutoSyncNotificationEvent,
|
||||
deps: CharacterDictionaryAutoSyncNotificationDeps,
|
||||
@@ -23,12 +35,18 @@ export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
const type = deps.getNotificationType();
|
||||
if (shouldShowOsd(type)) {
|
||||
if (deps.startupOsdSequencer) {
|
||||
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||
const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||
phase: event.phase,
|
||||
message: event.message,
|
||||
});
|
||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
deps.showOsd(event.message);
|
||||
const shown = deps.showOsd(event.message) !== false;
|
||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
|
||||
const build = createBuildCliCommandContextMainDepsHandler({
|
||||
appState,
|
||||
onMpvSocketPathChanged: (next, previous) => calls.push(`socket:${previous}->${next}`),
|
||||
texthookerService: { isRunning: () => false, start: () => null },
|
||||
getResolvedConfig: () => ({
|
||||
texthooker: { openBrowser: true },
|
||||
@@ -121,6 +122,10 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
|
||||
deps.setSocketPath('/tmp/next.sock');
|
||||
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
|
||||
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
|
||||
deps.setSocketPath('/tmp/next.sock');
|
||||
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
|
||||
calls.length = 0;
|
||||
assert.equal(deps.getTexthookerPort(), 5174);
|
||||
deps.setTexthookerPort(5175);
|
||||
assert.equal(appState.texthookerPort, 5175);
|
||||
|
||||
@@ -12,6 +12,7 @@ type CliCommandContextMainState = {
|
||||
export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
appState: CliCommandContextMainState;
|
||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void;
|
||||
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
|
||||
getResolvedConfig: () => {
|
||||
texthooker?: { openBrowser?: boolean };
|
||||
@@ -74,7 +75,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
setLogLevel: deps.setLogLevel,
|
||||
getSocketPath: () => deps.appState.mpvSocketPath,
|
||||
setSocketPath: (socketPath: string) => {
|
||||
const previousSocketPath = deps.appState.mpvSocketPath;
|
||||
deps.appState.mpvSocketPath = socketPath;
|
||||
if (socketPath !== previousSocketPath) {
|
||||
deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath);
|
||||
}
|
||||
},
|
||||
getMpvClient: () => deps.appState.mpvClient,
|
||||
showOsd: (text: string) => deps.showMpvOsd(text),
|
||||
|
||||
@@ -139,13 +139,38 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
|
||||
return detail ? `${fallback}: ${detail}` : fallback;
|
||||
}
|
||||
|
||||
function needsWindowsShell(command: string): boolean {
|
||||
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
function quoteForWindowsShell(value: string): string {
|
||||
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function createDefaultRunCommand(): RunCommand {
|
||||
return (command, args, options = {}) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: false,
|
||||
});
|
||||
const useShell = needsWindowsShell(command);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = useShell
|
||||
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: false,
|
||||
shell: true,
|
||||
})
|
||||
: spawn(command, args, {
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: false,
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
detectBun,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
resolveLauncherInstallTarget,
|
||||
type BunSnapshot,
|
||||
} from './command-line-launcher';
|
||||
import { getRunCommand } from './command-line-launcher-deps';
|
||||
|
||||
function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot {
|
||||
return {
|
||||
@@ -85,6 +88,48 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
|
||||
const scriptPath = path.join(tempDir, 'argv.cmd');
|
||||
const outputPath = path.join(tempDir, 'argv.txt');
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
'@echo off',
|
||||
'setlocal DisableDelayedExpansion',
|
||||
'> "%SUBMINER_ARGV_OUT%" (',
|
||||
' echo 1=%~1',
|
||||
' echo 2=%~2',
|
||||
' echo 3=%~3',
|
||||
' echo 4=%~4',
|
||||
' echo 5=%~5',
|
||||
' echo 6=%~6',
|
||||
')',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await getRunCommand({})(
|
||||
scriptPath,
|
||||
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
|
||||
{
|
||||
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(
|
||||
fs.readFileSync(outputPath, 'utf8'),
|
||||
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
|
||||
assert.deepEqual(
|
||||
resolveBunInstallCommand({
|
||||
|
||||
@@ -124,6 +124,8 @@ function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
|
||||
'ankiConnect.knownWords',
|
||||
'ankiConnect.nPlusOne',
|
||||
'ankiConnect.fields.word',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'subtitleStyle.nameMatchImagesEnabled',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ test('show mpv osd main deps map runtime delegates and logging callback', () =>
|
||||
showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => {
|
||||
calls.push(`show:${text}`);
|
||||
fallbackLog('fallback');
|
||||
return false;
|
||||
},
|
||||
getMpvClient: () => client,
|
||||
logInfo: (line) => calls.push(`info:${line}`),
|
||||
@@ -48,6 +49,9 @@ test('show mpv osd main deps map runtime delegates and logging callback', () =>
|
||||
|
||||
assert.deepEqual(deps.getMpvClient(), client);
|
||||
deps.appendToMpvLog('hello');
|
||||
deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => deps.logInfo(line));
|
||||
const shown = deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) =>
|
||||
deps.logInfo(line),
|
||||
);
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']);
|
||||
});
|
||||
|
||||
@@ -99,12 +99,14 @@ test('show mpv osd logs marker and forwards fallback logging', () => {
|
||||
showMpvOsdRuntime: (_client, text, fallbackLog) => {
|
||||
calls.push(`show:${text}`);
|
||||
fallbackLog('fallback-line');
|
||||
return false;
|
||||
},
|
||||
getMpvClient: () => client,
|
||||
logInfo: (line) => calls.push(`info:${line}`),
|
||||
});
|
||||
|
||||
showMpvOsd('subtitle copied');
|
||||
const shown = showMpvOsd('subtitle copied');
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(calls, [
|
||||
'append:[OSD] subtitle copied',
|
||||
'show:subtitle copied',
|
||||
|
||||
@@ -57,13 +57,13 @@ export function createShowMpvOsdHandler(deps: {
|
||||
mpvClient: MpvRuntimeClientLike | null,
|
||||
text: string,
|
||||
fallbackLog: (line: string) => void,
|
||||
) => void;
|
||||
) => boolean;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
logInfo: (line: string) => void;
|
||||
}) {
|
||||
return (text: string): void => {
|
||||
return (text: string): boolean => {
|
||||
deps.appendToMpvLog(`[OSD] ${text}`);
|
||||
deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => {
|
||||
return deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => {
|
||||
deps.logInfo(line);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,15 +19,17 @@ test('mpv osd runtime handlers compose append and osd logging flow', async () =>
|
||||
showMpvOsdRuntime: (_client, text, fallbackLog) => {
|
||||
calls.push(`show:${text}`);
|
||||
fallbackLog('fallback');
|
||||
return false;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
logInfo: (line) => calls.push(`info:${line}`),
|
||||
}),
|
||||
});
|
||||
|
||||
runtime.showMpvOsd('hello');
|
||||
const shown = runtime.showMpvOsd('hello');
|
||||
await runtime.flushMpvLog();
|
||||
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(calls, [
|
||||
'show:hello',
|
||||
'info:fallback',
|
||||
|
||||
@@ -100,7 +100,7 @@ test('startup OSD replaces earlier dictionary progress with later building progr
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
|
||||
test('startup OSD shows dictionary ready when progress completed before it became visible', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
@@ -117,7 +117,10 @@ test('startup OSD skips buffered dictionary ready messages when progress complet
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
|
||||
assert.deepEqual(osdMessages, ['Subtitle annotations loaded']);
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Character dictionary ready for Frieren',
|
||||
'Subtitle annotations loaded',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD shows dictionary failure after annotation loading completes', () => {
|
||||
@@ -184,3 +187,38 @@ test('startup OSD shows later dictionary progress immediately once tokenization
|
||||
'Generating character dictionary for Frieren...',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD keeps dictionary progress pending when mpv osd is unavailable', () => {
|
||||
const osdMessages: string[] = [];
|
||||
let osdAvailable = false;
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
return osdAvailable;
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||
);
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Generating character dictionary for Frieren...',
|
||||
'Character dictionary ready for Frieren',
|
||||
]);
|
||||
|
||||
osdAvailable = true;
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Generating character dictionary for Frieren...',
|
||||
'Character dictionary ready for Frieren',
|
||||
'Character dictionary ready for Frieren',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3,22 +3,24 @@ export interface StartupOsdSequencerCharacterDictionaryEvent {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => void }): {
|
||||
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => boolean | void }): {
|
||||
reset: () => void;
|
||||
markTokenizationReady: () => void;
|
||||
showAnnotationLoading: (message: string) => void;
|
||||
markAnnotationLoadingComplete: (message: string) => void;
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => boolean;
|
||||
} {
|
||||
let tokenizationReady = false;
|
||||
let tokenizationWarmupCompleted = false;
|
||||
let annotationLoadingMessage: string | null = null;
|
||||
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
let pendingDictionaryReady: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
let dictionaryProgressShown = false;
|
||||
|
||||
const canShowDictionaryStatus = (): boolean =>
|
||||
tokenizationReady && annotationLoadingMessage === null;
|
||||
const showOsd = (message: string): boolean => deps.showOsd(message) !== false;
|
||||
|
||||
const flushBufferedDictionaryStatus = (): boolean => {
|
||||
if (!canShowDictionaryStatus()) {
|
||||
@@ -28,15 +30,24 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
if (dictionaryProgressShown) {
|
||||
return true;
|
||||
}
|
||||
deps.showOsd(pendingDictionaryProgress.message);
|
||||
dictionaryProgressShown = true;
|
||||
return true;
|
||||
dictionaryProgressShown = showOsd(pendingDictionaryProgress.message);
|
||||
return dictionaryProgressShown;
|
||||
}
|
||||
if (pendingDictionaryReady) {
|
||||
const shown = showOsd(pendingDictionaryReady.message);
|
||||
if (shown) {
|
||||
pendingDictionaryReady = null;
|
||||
dictionaryProgressShown = false;
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
if (pendingDictionaryFailure) {
|
||||
deps.showOsd(pendingDictionaryFailure.message);
|
||||
pendingDictionaryFailure = null;
|
||||
dictionaryProgressShown = false;
|
||||
return true;
|
||||
const shown = showOsd(pendingDictionaryFailure.message);
|
||||
if (shown) {
|
||||
pendingDictionaryFailure = null;
|
||||
dictionaryProgressShown = false;
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -47,13 +58,14 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
annotationLoadingMessage = null;
|
||||
pendingDictionaryProgress = null;
|
||||
pendingDictionaryFailure = null;
|
||||
pendingDictionaryReady = null;
|
||||
dictionaryProgressShown = false;
|
||||
},
|
||||
markTokenizationReady: () => {
|
||||
tokenizationWarmupCompleted = true;
|
||||
tokenizationReady = true;
|
||||
if (annotationLoadingMessage !== null) {
|
||||
deps.showOsd(annotationLoadingMessage);
|
||||
showOsd(annotationLoadingMessage);
|
||||
return;
|
||||
}
|
||||
flushBufferedDictionaryStatus();
|
||||
@@ -61,7 +73,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
showAnnotationLoading: (message) => {
|
||||
annotationLoadingMessage = message;
|
||||
if (tokenizationReady) {
|
||||
deps.showOsd(message);
|
||||
showOsd(message);
|
||||
}
|
||||
},
|
||||
markAnnotationLoadingComplete: (message) => {
|
||||
@@ -72,7 +84,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
if (flushBufferedDictionaryStatus()) {
|
||||
return;
|
||||
}
|
||||
deps.showOsd(message);
|
||||
showOsd(message);
|
||||
},
|
||||
notifyCharacterDictionaryStatus: (event) => {
|
||||
if (
|
||||
@@ -84,32 +96,47 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
) {
|
||||
pendingDictionaryProgress = event;
|
||||
pendingDictionaryFailure = null;
|
||||
pendingDictionaryReady = null;
|
||||
if (canShowDictionaryStatus()) {
|
||||
deps.showOsd(event.message);
|
||||
dictionaryProgressShown = true;
|
||||
dictionaryProgressShown = showOsd(event.message);
|
||||
} else if (tokenizationReady) {
|
||||
deps.showOsd(event.message);
|
||||
dictionaryProgressShown = true;
|
||||
dictionaryProgressShown = showOsd(event.message);
|
||||
}
|
||||
return;
|
||||
return dictionaryProgressShown;
|
||||
}
|
||||
|
||||
pendingDictionaryProgress = null;
|
||||
if (event.phase === 'failed') {
|
||||
pendingDictionaryReady = null;
|
||||
if (canShowDictionaryStatus()) {
|
||||
deps.showOsd(event.message);
|
||||
if (!showOsd(event.message)) {
|
||||
pendingDictionaryFailure = event;
|
||||
return false;
|
||||
}
|
||||
dictionaryProgressShown = false;
|
||||
return true;
|
||||
} else {
|
||||
pendingDictionaryFailure = event;
|
||||
}
|
||||
dictionaryProgressShown = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
pendingDictionaryFailure = null;
|
||||
if (canShowDictionaryStatus() && dictionaryProgressShown) {
|
||||
deps.showOsd(event.message);
|
||||
if (canShowDictionaryStatus()) {
|
||||
if (!showOsd(event.message)) {
|
||||
pendingDictionaryReady = event;
|
||||
dictionaryProgressShown = false;
|
||||
return false;
|
||||
}
|
||||
pendingDictionaryReady = null;
|
||||
dictionaryProgressShown = false;
|
||||
return true;
|
||||
} else {
|
||||
pendingDictionaryReady = event;
|
||||
}
|
||||
dictionaryProgressShown = false;
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
getJlptLevel: () => 'N2',
|
||||
getJlptEnabled: () => true,
|
||||
getNameMatchEnabled: () => false,
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
getCharacterNameImage: (term) =>
|
||||
term === 'name' ? { src: 'data:image/png;base64,AAAA', alt: 'Name' } : null,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||
getFrequencyRank: () => 5,
|
||||
@@ -52,6 +55,11 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
assert.equal(deps.getNPlusOneEnabled?.(), true);
|
||||
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
|
||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||
assert.equal(deps.getNameMatchImagesEnabled?.(), true);
|
||||
assert.deepEqual(deps.getCharacterNameImage?.('name'), {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'Name',
|
||||
});
|
||||
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
|
||||
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
|
||||
});
|
||||
@@ -74,6 +82,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
|
||||
getJlptEnabled: () => true,
|
||||
getCharacterDictionaryEnabled: () => false,
|
||||
getNameMatchEnabled: () => true,
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||
getFrequencyRank: () => 5,
|
||||
@@ -82,6 +91,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
|
||||
})();
|
||||
|
||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||
assert.equal(deps.getNameMatchImagesEnabled?.(), false);
|
||||
});
|
||||
|
||||
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
||||
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
|
||||
getCharacterDictionaryEnabled?: () => boolean;
|
||||
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
|
||||
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
|
||||
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
|
||||
getFrequencyDictionaryEnabled: NonNullable<
|
||||
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
||||
>;
|
||||
@@ -57,6 +59,17 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getNameMatchImagesEnabled
|
||||
? {
|
||||
getNameMatchImagesEnabled: () =>
|
||||
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchImagesEnabled!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getCharacterNameImage
|
||||
? {
|
||||
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
|
||||
}
|
||||
: {}),
|
||||
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
||||
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||
|
||||
@@ -66,7 +66,8 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
openFirstRunSetupWindow: (force?: boolean) =>
|
||||
calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
@@ -91,7 +92,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'texthooker',
|
||||
'show-texthooker:true',
|
||||
'setup',
|
||||
'setup',
|
||||
'setup-forced',
|
||||
'yomitan',
|
||||
'configuration',
|
||||
'jellyfin',
|
||||
@@ -102,6 +103,42 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
const calls: string[] = [];
|
||||
const buildTemplate = createBuildTrayMenuTemplateHandler({
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
assert.equal(handlers.showFirstRunSetup, false);
|
||||
assert.equal(handlers.showWindowsMpvLauncherSetup, true);
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
return [{ label: 'ok' }] as never;
|
||||
},
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => false,
|
||||
openFirstRunSetupWindow: (force?: boolean) =>
|
||||
calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => false,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
},
|
||||
platform: 'win32',
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.deepEqual(buildTemplate(), [{ label: 'ok' }]);
|
||||
assert.deepEqual(calls, ['setup-forced']);
|
||||
});
|
||||
|
||||
test('texthooker tray visibility follows websocket server enabled state', () => {
|
||||
assert.equal(
|
||||
shouldShowTexthookerTrayEntry({
|
||||
|
||||
@@ -61,7 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
@@ -92,7 +92,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
},
|
||||
showFirstRunSetup: deps.showFirstRunSetup(),
|
||||
openWindowsMpvLauncherSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
deps.openFirstRunSetupWindow(true);
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
openYomitanSettings: () => {
|
||||
|
||||
@@ -28,7 +28,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
openFirstRunSetupWindow: (force?: boolean) =>
|
||||
calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
|
||||
@@ -57,6 +57,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
false,
|
||||
);
|
||||
assert.equal(template[0]!.label, 'Open Help');
|
||||
assert.equal(template[3]!.label, 'Open SubMiner Setup');
|
||||
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
|
||||
assert.equal(discovery?.type, 'checkbox');
|
||||
assert.equal(discovery?.checked, false);
|
||||
@@ -102,7 +103,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(labels.includes('Complete Setup'), false);
|
||||
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
|
||||
assert.equal(labels.includes('Open SubMiner Setup'), false);
|
||||
assert.equal(labels.includes('Jellyfin Discovery'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
...(handlers.showWindowsMpvLauncherSetup
|
||||
? [
|
||||
{
|
||||
label: 'Manage Windows mpv launcher',
|
||||
label: 'Open SubMiner Setup',
|
||||
click: handlers.openWindowsMpvLauncherSetup,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
|
||||
test('macOS release metadata fetch is skipped only when native updater is unsupported', () => {
|
||||
test('macOS automatic release metadata fetch is skipped when native updater is unsupported', () => {
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: false,
|
||||
@@ -28,6 +28,33 @@ test('macOS release metadata fetch is skipped only when native updater is unsupp
|
||||
);
|
||||
});
|
||||
|
||||
test('macOS manual checks fetch release metadata when native updater is unsupported', () => {
|
||||
const unsupportedUpdate = {
|
||||
available: false,
|
||||
version: '0.15.0-beta.4',
|
||||
canUpdate: false,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', unsupportedUpdate, {
|
||||
source: 'manual',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', unsupportedUpdate, {
|
||||
source: 'launcher',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', unsupportedUpdate, {
|
||||
source: 'automatic',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-macOS release metadata fetch is not gated by native updater support', () => {
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('linux', {
|
||||
|
||||
@@ -4,12 +4,20 @@ type AppUpdateMetadata = {
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
type UpdateMetadataRequest = {
|
||||
source?: 'manual' | 'automatic' | 'launcher';
|
||||
};
|
||||
|
||||
export function shouldFetchReleaseMetadataForPlatform(
|
||||
platform: NodeJS.Platform,
|
||||
appUpdate: AppUpdateMetadata,
|
||||
request: UpdateMetadataRequest = {},
|
||||
): boolean {
|
||||
if (platform !== 'darwin') {
|
||||
return true;
|
||||
}
|
||||
return appUpdate.canUpdate !== false;
|
||||
if (appUpdate.canUpdate !== false) {
|
||||
return true;
|
||||
}
|
||||
return request.source === 'manual' || request.source === 'launcher';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
|
||||
|
||||
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
@@ -362,6 +363,57 @@ test('manual prerelease update check uses prerelease release and launcher channe
|
||||
]);
|
||||
});
|
||||
|
||||
test('manual macOS prerelease check reports GitHub update when native updater is unsupported', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
channel: 'prerelease',
|
||||
}),
|
||||
getCurrentVersion: () => '0.15.0-beta.4',
|
||||
checkAppUpdate: async (channel) => {
|
||||
calls.push(`app:${channel}`);
|
||||
return {
|
||||
available: false,
|
||||
version: '0.15.0-beta.4',
|
||||
canUpdate: false,
|
||||
};
|
||||
},
|
||||
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', appUpdate, request),
|
||||
fetchLatestStableRelease: async (channel) => {
|
||||
calls.push(`fetch:${channel}`);
|
||||
return {
|
||||
tag_name: 'v0.15.0-beta.5',
|
||||
prerelease: true,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
updateLauncher: async (_launcherPath, channel, release) => {
|
||||
calls.push(`launcher:${channel}:${release?.tag_name ?? 'none'}`);
|
||||
return { status: 'skipped' };
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, [
|
||||
'app:prerelease',
|
||||
'fetch:prerelease',
|
||||
'available-dialog:0.15.0-beta.5',
|
||||
'launcher:prerelease:v0.15.0-beta.5',
|
||||
'manual-install:0.15.0-beta.5',
|
||||
]);
|
||||
});
|
||||
|
||||
test('manual update check keeps current prerelease builds on configured stable channel', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getCurrentVersion: () => '0.15.0-beta.3',
|
||||
|
||||
@@ -72,6 +72,7 @@ test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', ()
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
'C:\\a.mkv',
|
||||
@@ -100,6 +101,7 @@ test('buildWindowsMpvLaunchArgs inserts maximized launch mode before explicit ex
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
'--window-maximized=yes',
|
||||
@@ -129,6 +131,7 @@ test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () =
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
],
|
||||
@@ -154,6 +157,7 @@ test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script op
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--input-ipc-server',
|
||||
@@ -182,6 +186,7 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--input-ipc-server',
|
||||
@@ -223,6 +228,31 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly overridden', () => {
|
||||
const args = buildWindowsMpvLaunchArgs(
|
||||
['C:\\video.mkv'],
|
||||
[],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'normal',
|
||||
{
|
||||
socketPath: 'C:\\Users\\tester\\AppData\\Local\\Temp\\subminer-smoke-sock\\subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'windows',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F7',
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(args.includes('--input-ipc-server=\\\\.\\pipe\\subminer-socket'));
|
||||
const scriptOpts = args.find((arg) => arg.startsWith('--script-opts='));
|
||||
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\subminer-socket/);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', async () => {
|
||||
const errors: string[] = [];
|
||||
const result = await launchWindowsMpv(
|
||||
@@ -258,7 +288,7 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.deepEqual(calls, [
|
||||
'C:\\mpv\\mpv.exe',
|
||||
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
|
||||
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export function buildWindowsMpvLaunchArgs(
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
...(scriptOpts ? [scriptOpts] : []),
|
||||
...buildMpvLaunchModeArgs(launchMode),
|
||||
|
||||
+2
-2
@@ -413,8 +413,8 @@ const electronAPI: ElectronAPI = {
|
||||
request: YoutubePickerResolveRequest,
|
||||
): Promise<YoutubePickerResolveResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
|
||||
getCharacterDictionarySelection: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection),
|
||||
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle),
|
||||
setCharacterDictionarySelection: (mediaId: number) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
|
||||
notifyOverlayModalClosed: (modal) => {
|
||||
|
||||
@@ -87,6 +87,11 @@ test('prerelease workflow writes checksum entries using release asset basenames'
|
||||
);
|
||||
});
|
||||
|
||||
test('prerelease workflow relies on builder artifact names without post-build zip renames', () => {
|
||||
assert.doesNotMatch(prereleaseWorkflow, /Rename Windows ZIP artifacts/);
|
||||
assert.doesNotMatch(prereleaseWorkflow, /Rename-Item[\s\S]*-win\.zip/);
|
||||
});
|
||||
|
||||
test('prerelease workflow validates artifacts before publishing the release and only undrafts after upload', () => {
|
||||
const artifactsIndex = prereleaseWorkflow.indexOf('artifacts=(');
|
||||
const createIndex = prereleaseWorkflow.indexOf('gh release create');
|
||||
|
||||
@@ -18,15 +18,28 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
afterPack?: string;
|
||||
electronUpdaterCompatibility?: string;
|
||||
files?: string[];
|
||||
artifactName?: string;
|
||||
dmg?: {
|
||||
artifactName?: string;
|
||||
};
|
||||
extraResources?: Array<{
|
||||
from?: string;
|
||||
to?: string;
|
||||
}>;
|
||||
mac?: {
|
||||
artifactName?: string;
|
||||
};
|
||||
nsis?: {
|
||||
artifactName?: string;
|
||||
};
|
||||
publish?: Array<{
|
||||
provider?: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
}>;
|
||||
win?: {
|
||||
artifactName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -199,6 +212,15 @@ test('windows release workflow publishes unsigned artifacts directly without Sig
|
||||
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
|
||||
});
|
||||
|
||||
test('release artifact names are distinct before upload', () => {
|
||||
assert.equal(packageJson.build?.mac?.artifactName, 'SubMiner-${version}-mac.${ext}');
|
||||
assert.equal(packageJson.build?.dmg?.artifactName, 'SubMiner-${version}.${ext}');
|
||||
assert.equal(packageJson.build?.win?.artifactName, 'SubMiner-${version}-win.${ext}');
|
||||
assert.equal(packageJson.build?.nsis?.artifactName, 'SubMiner-${version}.${ext}');
|
||||
assert.doesNotMatch(releaseWorkflow, /Rename Windows ZIP artifacts/);
|
||||
assert.doesNotMatch(releaseWorkflow, /Rename-Item[\s\S]*-win\.zip/);
|
||||
});
|
||||
|
||||
test('release workflow publishes subminer-bin to AUR from tagged release artifacts', () => {
|
||||
assert.match(releaseWorkflow, /aur-publish:/);
|
||||
assert.match(releaseWorkflow, /needs:\s*\[release\]/);
|
||||
|
||||
@@ -681,6 +681,7 @@ test('numeric selection start focuses overlay for follow-up digit keys', async (
|
||||
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||
} finally {
|
||||
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
+17
-1
@@ -22,7 +22,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
|
||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; img-src 'self' data: blob: chrome-extension:; worker-src 'self' blob:;"
|
||||
/>
|
||||
<title>SubMiner</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
@@ -205,6 +205,22 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
||||
<div class="character-dictionary-search">
|
||||
<input
|
||||
id="characterDictionarySearchInput"
|
||||
class="character-dictionary-search-input"
|
||||
type="text"
|
||||
aria-label="Search character dictionary"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="characterDictionarySearchButton"
|
||||
class="character-dictionary-use"
|
||||
type="button"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user