make pretty

This commit is contained in:
2026-03-02 02:45:51 -08:00
parent 83d21c4b6d
commit be4db24861
42 changed files with 395 additions and 336 deletions

View File

@@ -18,6 +18,7 @@ ordinal: 8000
Add a user-facing subtitle config option to pause mpv playback when the cursor hovers subtitle text and resume playback when the cursor leaves. Add a user-facing subtitle config option to pause mpv playback when the cursor hovers subtitle text and resume playback when the cursor leaves.
Scope: Scope:
- New config key: `subtitleStyle.autoPauseVideoOnHover`. - New config key: `subtitleStyle.autoPauseVideoOnHover`.
- Default should be enabled. - Default should be enabled.
- Hover pause/resume must not unpause if playback was already paused before hover. - Hover pause/resume must not unpause if playback was already paused before hover.

View File

@@ -18,6 +18,7 @@ ordinal: 9000
Add startup gating behavior for wrapper + mpv plugin flow so playback starts paused when visible overlay auto-start is enabled, then auto-resumes only after subtitle tokenization is ready. Add startup gating behavior for wrapper + mpv plugin flow so playback starts paused when visible overlay auto-start is enabled, then auto-resumes only after subtitle tokenization is ready.
Scope: Scope:
- Plugin option `auto_start_pause_until_ready` (default `yes`). - Plugin option `auto_start_pause_until_ready` (default `yes`).
- Launcher reads plugin runtime config and starts mpv paused when `auto_start=yes`, `auto_start_visible_overlay=yes`, and `auto_start_pause_until_ready=yes`. - Launcher reads plugin runtime config and starts mpv paused when `auto_start=yes`, `auto_start_visible_overlay=yes`, and `auto_start_pause_until_ready=yes`.
- Main process signals readiness via mpv script message after tokenized subtitle delivery. - Main process signals readiness via mpv script message after tokenized subtitle delivery.
@@ -43,6 +44,7 @@ Scope:
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented startup pause gate across launcher/plugin/main runtime: Implemented startup pause gate across launcher/plugin/main runtime:
- Added plugin runtime config parsing in launcher (`auto_start`, `auto_start_visible_overlay`, `auto_start_pause_until_ready`) and mpv start-paused behavior for eligible runs. - Added plugin runtime config parsing in launcher (`auto_start`, `auto_start_visible_overlay`, `auto_start_pause_until_ready`) and mpv start-paused behavior for eligible runs.
- Added plugin auto-play gate state, timeout fallback, and readiness release via `subminer-autoplay-ready` script message. - Added plugin auto-play gate state, timeout fallback, and readiness release via `subminer-autoplay-ready` script message.
- Added main-process readiness signaling after tokenization delivery, including unpause fallback command path. - Added main-process readiness signaling after tokenization delivery, including unpause fallback command path.

View File

@@ -18,10 +18,12 @@ ordinal: 10000
Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds. Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds.
Current behavior: Current behavior:
- Subtitle file downloads and loads into mpv. - Subtitle file downloads and loads into mpv.
- Jimaku modal remains open until manual close. - Jimaku modal remains open until manual close.
Expected behavior: Expected behavior:
- On successful `jimakuDownloadFile` result, close modal immediately. - On successful `jimakuDownloadFile` result, close modal immediately.
- Keep error behavior unchanged (stay open + show error). - Keep error behavior unchanged (stay open + show error).

View File

@@ -18,11 +18,13 @@ ordinal: 11000
When user selects a Jimaku subtitle, save subtitle with filename derived from currently playing media filename instead of Jimaku release filename. When user selects a Jimaku subtitle, save subtitle with filename derived from currently playing media filename instead of Jimaku release filename.
Example: Example:
- Current media: `anime.mkv` - Current media: `anime.mkv`
- Downloaded subtitle extension: `.srt` - Downloaded subtitle extension: `.srt`
- Saved subtitle path: `anime.ja.srt` - Saved subtitle path: `anime.ja.srt`
Scope: Scope:
- Apply in Jimaku download IPC path before writing file. - Apply in Jimaku download IPC path before writing file.
- Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists). - Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists).
- Keep mpv load flow unchanged except using renamed path. - Keep mpv load flow unchanged except using renamed path.

View File

@@ -16,6 +16,7 @@ ordinal: 9001
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Reduce subtitle annotation latency by: Reduce subtitle annotation latency by:
- disabling Yomitan-side MeCab parser requests (`useMecabParser=false`); - disabling Yomitan-side MeCab parser requests (`useMecabParser=false`);
- initializing local MeCab only when POS-dependent annotations are enabled (N+1 / JLPT / frequency); - initializing local MeCab only when POS-dependent annotations are enabled (N+1 / JLPT / frequency);
- replacing per-line local MeCab process spawning with a persistent parser process that auto-shuts down after idle time and restarts on demand. - replacing per-line local MeCab process spawning with a persistent parser process that auto-shuts down after idle time and restarts on demand.
@@ -39,6 +40,7 @@ Reduce subtitle annotation latency by:
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented tokenizer latency optimizations: Implemented tokenizer latency optimizations:
- switched Yomitan parse requests to `useMecabParser: false`; - switched Yomitan parse requests to `useMecabParser: false`;
- added annotation-aware MeCab initialization gating in runtime warmup flow; - added annotation-aware MeCab initialization gating in runtime warmup flow;
- added persistent local MeCab process (default idle shutdown: 30s) with queued requests, retry-on-process-end, idle auto-shutdown, and automatic restart on new work; - added persistent local MeCab process (default idle shutdown: 30s) with queued requests, retry-on-process-end, idle auto-shutdown, and automatic restart on new work;

View File

@@ -16,10 +16,12 @@ ordinal: 9002
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Address frequency-highlighting regressions: Address frequency-highlighting regressions:
- tokens like `断じて` missed rank assignment when Yomitan merged-token reading was truncated/noisy; - tokens like `断じて` missed rank assignment when Yomitan merged-token reading was truncated/noisy;
- known/N+1 tokens were incorrectly colored by frequency color instead of known/N+1 color. - known/N+1 tokens were incorrectly colored by frequency color instead of known/N+1 color.
Expected behavior: Expected behavior:
- known/N+1 color always wins; - known/N+1 color always wins;
- if token is frequent and within `topX`, frequency rank label can still appear on hover/metadata. - if token is frequent and within `topX`, frequency rank label can still appear on hover/metadata.
@@ -42,6 +44,7 @@ Expected behavior:
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented and validated: Implemented and validated:
- tokenizer now normalizes selected Yomitan merged-token readings by appending missing trailing kana suffixes when safe (`headword === surface`); - tokenizer now normalizes selected Yomitan merged-token readings by appending missing trailing kana suffixes when safe (`headword === surface`);
- frequency lookup now does lazy fallback: requests `{term, reading}` first, and only requests `{term, reading: null}` for misses; - frequency lookup now does lazy fallback: requests `{term, reading}` first, and only requests `{term, reading: null}` for misses;
- this removes eager `(term, null)` payload inflation on medium-frequency lines and reduces extension RPC payload/load; - this removes eager `(term, null)` payload inflation on medium-frequency lines and reduces extension RPC payload/load;
@@ -50,6 +53,7 @@ Implemented and validated:
- added regression tests covering noisy-reading fallback, lazy fallback-query behavior, and renderer class/label precedence. - added regression tests covering noisy-reading fallback, lazy fallback-query behavior, and renderer class/label precedence.
Related commits: Related commits:
- `17a417e` (`fix(subtitle): improve frequency highlight reliability`) - `17a417e` (`fix(subtitle): improve frequency highlight reliability`)
- `79f37f3` (`fix(subtitle): prioritize known and n+1 colors over frequency`) - `79f37f3` (`fix(subtitle): prioritize known and n+1 colors over frequency`)

View File

@@ -18,6 +18,7 @@ ordinal: 9003
Add keybinding-friendly special commands that shift `sub-delay` to align current subtitle start with next/previous cue start, without `sub-seek` probing (avoid playback jump). Add keybinding-friendly special commands that shift `sub-delay` to align current subtitle start with next/previous cue start, without `sub-seek` probing (avoid playback jump).
Scope: Scope:
- add special commands for next/previous line alignment; - add special commands for next/previous line alignment;
- compute delta from active subtitle cue timeline (external subtitle file/URL, including Jellyfin-delivered URLs); - compute delta from active subtitle cue timeline (external subtitle file/URL, including Jellyfin-delivered URLs);
- apply `add sub-delay <delta>` and show OSD value; - apply `add sub-delay <delta>` and show OSD value;
@@ -42,6 +43,7 @@ Scope:
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented no-jump subtitle-delay alignment commands: Implemented no-jump subtitle-delay alignment commands:
- added `__sub-delay-next-line` and `__sub-delay-prev-line` special commands; - added `__sub-delay-next-line` and `__sub-delay-prev-line` special commands;
- added `createShiftSubtitleDelayToAdjacentCueHandler` to parse cue start times from active external subtitle source and apply `add sub-delay` delta from current `sub-start`; - added `createShiftSubtitleDelayToAdjacentCueHandler` to parse cue start times from active external subtitle source and apply `add sub-delay` delta from current `sub-start`;
- wired command handling through IPC runtime deps into main runtime; - wired command handling through IPC runtime deps into main runtime;

View File

@@ -125,8 +125,8 @@ Control the minimum log level for runtime output:
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------- | ----------------------------------- | ------------------------------------------------ | | ------- | ---------------------------------------- | --------------------------------------------------------- |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) | | `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
### Auto-Start Overlay ### Auto-Start Overlay
@@ -258,7 +258,7 @@ See `config.example.jsonc` for detailed configuration options.
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) | | `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `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. | | `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). | | `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) | | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
@@ -322,6 +322,7 @@ Set the initial vertical subtitle position (measured from the bottom of the scre
| Option | Values | Description | | Option | Values | Description |
| ---------- | ---------------- | ---------------------------------------------------------------------- | | ---------- | ---------------- | ---------------------------------------------------------------------- |
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) | | `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text. In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
### Secondary Subtitles ### Secondary Subtitles
@@ -364,23 +365,23 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:** **Default keybindings:**
| Key | Command | Description | | Key | Command | Description |
| ----------------- | ---------------------------- | ------------------------------------- | | -------------------- | ---------------------------- | ------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | | `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | | `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | | `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | | `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue | | `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue | | `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | | `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | | `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv | | `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv | | `Ctrl+KeyW` | `["quit"]` | Quit mpv |
**Custom keybindings example:** **Custom keybindings example:**

View File

@@ -79,18 +79,18 @@ Use `subminer <subcommand> -h` for command-specific help.
## Options ## Options
| Flag | Description | | Flag | Description |
| ----------------------- | --------------------------------------------------- | | --------------------- | --------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) | | `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively | | `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf | | `-R, --rofi` | Use rofi instead of fzf |
| `--start` | Explicitly start overlay after mpv launches | | `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches | | `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker` | Disable texthooker server | | `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (default: `subminer`) | | `-p, --profile` | mpv profile name (default: `subminer`) |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) | | `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | | `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary. With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.

View File

@@ -120,27 +120,27 @@ aniskip_button_duration=3
### Option Reference ### Option Reference
| Option | Default | Values | Description | | Option | Default | Values | Description |
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | | ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary | | `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path | | `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | | `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
| `texthooker_port` | `5174` | 165535 | Texthooker server port | | `texthooker_port` | `5174` | 165535 | Texthooker server port |
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend | | `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` | | `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` | | `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready | | `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | | `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | | `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection | | `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
| `aniskip_title` | `""` | string | Override title used for lookup | | `aniskip_title` | `""` | string | Override title used for lookup |
| `aniskip_season` | `""` | numeric season | Optional season hint | | `aniskip_season` | `""` | numeric season | Optional season hint |
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id | | `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed | | `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt | | `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) | | `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) | | `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration | | `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
## Binary Auto-Detection ## Binary Auto-Detection

View File

@@ -5,7 +5,7 @@ const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsThemeContents = readFileSync(docsThemePath, 'utf8'); const docsThemeContents = readFileSync(docsThemePath, 'utf8');
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => { test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => {
expect(docsThemeContents).toContain("@plausible-analytics/tracker"); expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import'); expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain("domain: 'subminer.moe'"); expect(docsThemeContents).toContain("domain: 'subminer.moe'");
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'"); expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'");

View File

@@ -46,8 +46,8 @@ These control playback and subtitle display. They require overlay window focus.
| `ArrowDown` | Seek backward 60 seconds | | `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle | | `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle | | `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue | | `Shift+[` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue | | `Shift+]` | Shift subtitle delay to next subtitle cue |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | | `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | | `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv | | `Q` | Quit mpv |

View File

@@ -143,11 +143,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
pluginRuntimeConfig.autoStartPauseUntilReady; pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) { if (shouldPauseUntilOverlayReady) {
log( log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
'info',
args.logLevel,
'Configured to pause mpv until overlay and tokenization are ready',
);
} }
startMpv( startMpv(
@@ -198,11 +194,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
if (ready) { if (ready) {
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else { } else {
log( log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
'info',
args.logLevel,
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
);
} }
} else if (ready) { } else if (ready) {
log( log(

View File

@@ -52,7 +52,10 @@ export function parsePluginRuntimeConfigContent(
continue; continue;
} }
if (key === 'auto_start_visible_overlay') { if (key === 'auto_start_visible_overlay') {
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value); runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
'auto_start_visible_overlay',
value,
);
continue; continue;
} }
if (key === 'auto_start_pause_until_ready') { if (key === 'auto_start_pause_until_ready') {

View File

@@ -239,8 +239,7 @@ export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAu
const serverUrl = sanitizeServerUrl( const serverUrl = sanitizeServerUrl(
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '', typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
); );
const accessToken = const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : ''; const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
if (!serverUrl || !accessToken) return null; if (!serverUrl || !accessToken) return null;
@@ -271,9 +270,7 @@ export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number):
const buffer = fs.readFileSync(logPath); const buffer = fs.readFileSync(logPath);
if (buffer.length === 0) return ''; if (buffer.length === 0) return '';
const normalizedOffset = const normalizedOffset =
Number.isFinite(offsetBytes) && offsetBytes >= 0 Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
? Math.floor(offsetBytes)
: 0;
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset; const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
return buffer.subarray(startOffset).toString('utf8'); return buffer.subarray(startOffset).toString('utf8');
} catch { } catch {
@@ -399,7 +396,9 @@ async function runAppJellyfinCommand(
const hasCommandSignal = (output: string): boolean => { const hasCommandSignal = (output: string): boolean => {
if (label === 'jellyfin-libraries') { if (label === 'jellyfin-libraries') {
return output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.'); return (
output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.')
);
} }
if (label === 'jellyfin-items') { if (label === 'jellyfin-items') {
return ( return (
@@ -550,7 +549,9 @@ async function resolveJellyfinSelectionViaApp(
} }
const configuredDefaultLibraryId = session.defaultLibraryId; const configuredDefaultLibraryId = session.defaultLibraryId;
const hasConfiguredDefault = libraries.some((library) => library.id === configuredDefaultLibraryId); const hasConfiguredDefault = libraries.some(
(library) => library.id === configuredDefaultLibraryId,
);
let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : ''; let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : '';
if (!libraryId) { if (!libraryId) {
libraryId = pickLibrary( libraryId = pickLibrary(

View File

@@ -333,7 +333,10 @@ test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () =>
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."} [subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
`); `);
assert.equal(parsed, '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}'); assert.equal(
parsed,
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
);
}); });
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => { test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
@@ -385,7 +388,9 @@ test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle er
true, true,
); );
assert.equal( assert.equal(
shouldRetryWithStartForNoRunningInstance('Missing Jellyfin session. Run --jellyfin-login first.'), shouldRetryWithStartForNoRunningInstance(
'Missing Jellyfin session. Run --jellyfin-login first.',
),
false, false,
); );
}); });
@@ -407,10 +412,13 @@ test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte lo
}); });
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => { test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
assert.deepEqual(parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), { assert.deepEqual(
seriesName: 'KONOSUBA', parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
seasonNumber: 1, {
}); seriesName: 'KONOSUBA',
seasonNumber: 1,
},
);
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), { assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
seriesName: 'Frieren', seriesName: 'Frieren',
seasonNumber: 2, seasonNumber: 2,

View File

@@ -86,8 +86,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
} }
const separatorIndex = trimmedPath.search(/[?#]/); const separatorIndex = trimmedPath.search(/[?#]/);
const pathWithoutQuery = const pathWithoutQuery = separatorIndex >= 0 ? trimmedPath.slice(0, separatorIndex) : trimmedPath;
separatorIndex >= 0 ? trimmedPath.slice(0, separatorIndex) : trimmedPath;
return decodeURIComponentSafe(path.basename(pathWithoutQuery)); return decodeURIComponentSafe(path.basename(pathWithoutQuery));
} }

View File

@@ -1,6 +1,11 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args'; import {
hasExplicitCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
} from './args';
test('parseArgs parses booleans and value flags', () => { test('parseArgs parses booleans and value flags', () => {
const args = parseArgs([ const args = parseArgs([
@@ -148,10 +153,7 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
'/tmp/subminer-jf-response.json', '/tmp/subminer-jf-response.json',
]); ]);
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true); assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
assert.equal( assert.equal(jellyfinPreviewAuth.jellyfinResponsePath, '/tmp/subminer-jf-response.json');
jellyfinPreviewAuth.jellyfinResponsePath,
'/tmp/subminer-jf-response.json',
);
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true); assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
assert.equal(shouldStartApp(jellyfinPreviewAuth), false); assert.equal(shouldStartApp(jellyfinPreviewAuth), false);

View File

@@ -240,7 +240,9 @@ export function parseArgs(argv: string[]): CliArgs {
if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true; if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true;
if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false; if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false;
} else if (arg === '--jellyfin-recursive') { } else if (arg === '--jellyfin-recursive') {
const value = readValue(argv[i + 1])?.trim().toLowerCase(); const value = readValue(argv[i + 1])
?.trim()
.toLowerCase();
if (value === 'false' || value === '0' || value === 'no') { if (value === 'false' || value === '0' || value === 'no') {
args.jellyfinRecursive = false; args.jellyfinRecursive = false;
} else if (value === 'true' || value === '1' || value === 'yes') { } else if (value === 'true' || value === '1' || value === 'yes') {

View File

@@ -47,7 +47,10 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision'); assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)'); assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)'); assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Inter, Noto Sans, Helvetica Neue, sans-serif'); assert.equal(
config.subtitleStyle.secondary.fontFamily,
'Inter, Noto Sans, Helvetica Neue, sans-serif',
);
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5'); assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.immersionTracking.enabled, true); assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, ''); assert.equal(config.immersionTracking.dbPath, '');

View File

@@ -99,8 +99,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (isObject(src.subtitleStyle)) { if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleAutoPauseVideoOnHover = const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor = const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor; resolved.subtitleStyle.hoverTokenBackgroundColor;
@@ -161,8 +160,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (autoPauseVideoOnHover !== undefined) { if (autoPauseVideoOnHover !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover; resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover;
} else if ( } else if (
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !== (src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !== undefined
undefined
) { ) {
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover; resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
warn( warn(

View File

@@ -46,23 +46,31 @@ export function pruneRetention(
const dayCutoff = nowMs - policy.dailyRollupRetentionMs; const dayCutoff = nowMs - policy.dailyRollupRetentionMs;
const monthCutoff = nowMs - policy.monthlyRollupRetentionMs; const monthCutoff = nowMs - policy.monthlyRollupRetentionMs;
const deletedSessionEvents = (db const deletedSessionEvents = (
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`) db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff) as {
.run(eventCutoff) as { changes: number }).changes; changes: number;
const deletedTelemetryRows = (db }
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`) ).changes;
.run(telemetryCutoff) as { changes: number }).changes; const deletedTelemetryRows = (
const deletedDailyRows = (db db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as {
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`) changes: number;
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }).changes; }
const deletedMonthlyRows = (db ).changes;
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`) const deletedDailyRows = (
.run(toMonthKey(monthCutoff)) as { changes: number }).changes; db
const deletedEndedSessions = (db .prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
.prepare( .run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }
`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`, ).changes;
) const deletedMonthlyRows = (
.run(telemetryCutoff) as { changes: number }).changes; db
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
.run(toMonthKey(monthCutoff)) as { changes: number }
).changes;
const deletedEndedSessions = (
db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
.run(telemetryCutoff) as { changes: number }
).changes;
return { return {
deletedSessionEvents, deletedSessionEvents,

View File

@@ -17,6 +17,9 @@ test('extractLineVocabulary returns words and unique kanji', () => {
new Set(result.words.map((entry) => `${entry.headword}/${entry.word}`)), new Set(result.words.map((entry) => `${entry.headword}/${entry.word}`)),
new Set(['hello/hello', '你好/你好', '猫/猫']), new Set(['hello/hello', '你好/你好', '猫/猫']),
); );
assert.equal(result.words.every((entry) => entry.reading === ''), true); assert.equal(
result.words.every((entry) => entry.reading === ''),
true,
);
assert.deepEqual(new Set(result.kanji), new Set(['你', '好', '猫'])); assert.deepEqual(new Set(result.kanji), new Set(['你', '好', '猫']));
}); });

View File

@@ -97,7 +97,8 @@ export function extractLineVocabulary(value: string): ExtractedLineVocabulary {
if (!cleaned) return { words: [], kanji: [] }; if (!cleaned) return { words: [], kanji: [] };
const wordSet = new Set<string>(); const wordSet = new Set<string>();
const tokenPattern = /[A-Za-z0-9']+|[\u3040-\u30ff]+|[\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df]+/g; const tokenPattern =
/[A-Za-z0-9']+|[\u3040-\u30ff]+|[\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df]+/g;
const rawWords = cleaned.match(tokenPattern) ?? []; const rawWords = cleaned.match(tokenPattern) ?? [];
for (const rawWord of rawWords) { for (const rawWord of rawWords) {
const normalizedWord = normalizeText(rawWord.toLowerCase()); const normalizedWord = normalizeText(rawWord.toLowerCase());

View File

@@ -19,15 +19,8 @@ export function startSessionRecord(
CREATED_DATE, LAST_UPDATE_DATE CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, nowMs);
sessionUuid,
videoId,
startedAtMs,
SESSION_STATUS_ACTIVE,
startedAtMs,
nowMs,
);
const sessionId = Number(result.lastInsertRowid); const sessionId = Number(result.lastInsertRowid);
return { return {
sessionId, sessionId,

View File

@@ -59,9 +59,7 @@ testIfSqlite('ensureSchema creates immersion core tables', () => {
assert.ok(tableNames.has('imm_rollup_state')); assert.ok(tableNames.has('imm_rollup_state'));
const rollupStateRow = db const rollupStateRow = db
.prepare( .prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
'SELECT state_value FROM imm_rollup_state WHERE state_key = ?',
)
.get('last_rollup_sample_ms') as { .get('last_rollup_sample_ms') as {
state_value: number; state_value: number;
} | null; } | null;
@@ -188,7 +186,9 @@ testIfSqlite('executeQueuedWrite inserts and upserts word and kanji rows', () =>
stmts.kanjiUpsertStmt.run('日', 8.0, 11.0); stmts.kanjiUpsertStmt.run('日', 8.0, 11.0);
const wordRow = db const wordRow = db
.prepare('SELECT headword, frequency, first_seen, last_seen FROM imm_words WHERE headword = ?') .prepare(
'SELECT headword, frequency, first_seen, last_seen FROM imm_words WHERE headword = ?',
)
.get('猫') as { .get('猫') as {
headword: string; headword: string;
frequency: number; frequency: number;

View File

@@ -426,11 +426,7 @@ export function getOrCreateVideoRecord(
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE video_id = ? WHERE video_id = ?
`, `,
).run( ).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
details.canonicalTitle || 'unknown',
Date.now(),
existing.video_id,
);
return existing.video_id; return existing.video_id;
} }

View File

@@ -129,7 +129,11 @@ interface QueuedKanjiWrite {
lastSeen: number; lastSeen: number;
} }
export type QueuedWrite = QueuedTelemetryWrite | QueuedEventWrite | QueuedWordWrite | QueuedKanjiWrite; export type QueuedWrite =
| QueuedTelemetryWrite
| QueuedEventWrite
| QueuedWordWrite
| QueuedKanjiWrite;
export interface VideoMetadata { export interface VideoMetadata {
sourceType: number; sourceType: number;

View File

@@ -31,7 +31,10 @@ test('createJlptVocabularyLookup loads JLPT bank entries and resolves known leve
assert.equal(lookup('猫'), 'N5'); assert.equal(lookup('猫'), 'N5');
assert.equal(lookup('犬'), 'N5'); assert.equal(lookup('犬'), 'N5');
assert.equal(lookup('鳥'), null); assert.equal(lookup('鳥'), null);
assert.equal(logs.some((entry) => entry.includes('JLPT dictionary loaded from')), true); assert.equal(
logs.some((entry) => entry.includes('JLPT dictionary loaded from')),
true,
);
}); });
test('createJlptVocabularyLookup does not require synchronous fs APIs', async () => { test('createJlptVocabularyLookup does not require synchronous fs APIs', async () => {

View File

@@ -53,7 +53,9 @@ function parseAssStartTimes(content: string): number[] {
const starts: number[] = []; const starts: number[] = [];
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
for (const line of lines) { for (const line of lines) {
const match = line.match(/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/); const match = line.match(
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
);
if (!match) continue; if (!match) continue;
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':'); const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
if (secondsRaw === undefined) continue; if (secondsRaw === undefined) continue;

View File

@@ -2370,7 +2370,6 @@ test('tokenizeSubtitle keeps frequency enrichment while n+1 is disabled', async
assert.equal(frequencyCalls, 1); assert.equal(frequencyCalls, 1);
}); });
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and frequency annotations', async () => { test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and frequency annotations', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'になれば', 'になれば',

View File

@@ -92,13 +92,14 @@ interface TokenizerAnnotationOptions {
pos2Exclusions: ReadonlySet<string>; pos2Exclusions: ReadonlySet<string>;
} }
let parserEnrichmentWorkerRuntimeModulePromise: let parserEnrichmentWorkerRuntimeModulePromise: Promise<
| Promise<typeof import('./tokenizer/parser-enrichment-worker-runtime')> typeof import('./tokenizer/parser-enrichment-worker-runtime')
| null = null; > | null = null;
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null = null; let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null =
let parserEnrichmentFallbackModulePromise: null;
| Promise<typeof import('./tokenizer/parser-enrichment-stage')> let parserEnrichmentFallbackModulePromise: Promise<
| null = null; typeof import('./tokenizer/parser-enrichment-stage')
> | null = null;
const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet( const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG, DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
); );
@@ -106,7 +107,10 @@ const DEFAULT_ANNOTATION_POS2_EXCLUSIONS = resolveAnnotationPos2ExclusionSet(
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG, DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
); );
function getKnownWordLookup(deps: TokenizerServiceDeps, options: TokenizerAnnotationOptions): (text: string) => boolean { function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
if (!options.nPlusOneEnabled) { if (!options.nPlusOneEnabled) {
return () => false; return () => false;
} }
@@ -126,7 +130,8 @@ async function enrichTokensWithMecabAsync(
mecabTokens: MergedToken[] | null, mecabTokens: MergedToken[] | null,
): Promise<MergedToken[]> { ): Promise<MergedToken[]> {
if (!parserEnrichmentWorkerRuntimeModulePromise) { if (!parserEnrichmentWorkerRuntimeModulePromise) {
parserEnrichmentWorkerRuntimeModulePromise = import('./tokenizer/parser-enrichment-worker-runtime'); parserEnrichmentWorkerRuntimeModulePromise =
import('./tokenizer/parser-enrichment-worker-runtime');
} }
try { try {
@@ -185,8 +190,7 @@ export function createTokenizerDepsRuntime(
getNPlusOneEnabled: options.getNPlusOneEnabled, getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled, getJlptEnabled: options.getJlptEnabled,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled, getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyDictionaryMatchMode: getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
getFrequencyRank: options.getFrequencyRank, getFrequencyRank: options.getFrequencyRank,
getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3), getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3),
getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false), getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false),
@@ -348,7 +352,8 @@ function buildYomitanFrequencyRankMap(
continue; continue;
} }
const dictionaryPriority = const dictionaryPriority =
typeof frequency.dictionaryPriority === 'number' && Number.isFinite(frequency.dictionaryPriority) typeof frequency.dictionaryPriority === 'number' &&
Number.isFinite(frequency.dictionaryPriority)
? Math.max(0, Math.floor(frequency.dictionaryPriority)) ? Math.max(0, Math.floor(frequency.dictionaryPriority))
: Number.MAX_SAFE_INTEGER; : Number.MAX_SAFE_INTEGER;
const current = rankByTerm.get(normalizedTerm); const current = rankByTerm.get(normalizedTerm);
@@ -489,7 +494,11 @@ async function parseWithYomitanInternalParser(
normalizedSelectedTokens, normalizedSelectedTokens,
frequencyMatchMode, frequencyMatchMode,
); );
const yomitanFrequencies = await requestYomitanTermFrequencies(termReadingList, deps, logger); const yomitanFrequencies = await requestYomitanTermFrequencies(
termReadingList,
deps,
logger,
);
return buildYomitanFrequencyRankMap(yomitanFrequencies); return buildYomitanFrequencyRankMap(yomitanFrequencies);
})() })()
: Promise.resolve(new Map<string, number>()); : Promise.resolve(new Map<string, number>());

View File

@@ -101,7 +101,7 @@ test('enrichTokensWithMecabPos1 avoids repeated active-candidate filter scans',
let sentinelFilterCalls = 0; let sentinelFilterCalls = 0;
const originalFilter = Array.prototype.filter; const originalFilter = Array.prototype.filter;
Array.prototype.filter = (function filterWithSentinelCheck( Array.prototype.filter = function filterWithSentinelCheck(
this: unknown[], this: unknown[],
...args: any[] ...args: any[]
): any[] { ): any[] {
@@ -113,7 +113,7 @@ test('enrichTokensWithMecabPos1 avoids repeated active-candidate filter scans',
} }
} }
return (originalFilter as (...params: any[]) => any[]).apply(this, args); return (originalFilter as (...params: any[]) => any[]).apply(this, args);
}) as typeof Array.prototype.filter; } as typeof Array.prototype.filter;
try { try {
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens); const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);

View File

@@ -182,7 +182,8 @@ function pickClosestMecabPosMetadataBySurface(
startDistance < bestSurfaceMatchDistance || startDistance < bestSurfaceMatchDistance ||
(startDistance === bestSurfaceMatchDistance && (startDistance === bestSurfaceMatchDistance &&
(endDistance < bestSurfaceMatchEndDistance || (endDistance < bestSurfaceMatchEndDistance ||
(endDistance === bestSurfaceMatchEndDistance && candidate.index < bestSurfaceMatchIndex))) (endDistance === bestSurfaceMatchEndDistance &&
candidate.index < bestSurfaceMatchIndex)))
) { ) {
bestSurfaceMatchDistance = startDistance; bestSurfaceMatchDistance = startDistance;
bestSurfaceMatchEndDistance = endDistance; bestSurfaceMatchEndDistance = endDistance;
@@ -199,7 +200,8 @@ function pickClosestMecabPosMetadataBySurface(
startDistance < bestSurfaceMatchDistance || startDistance < bestSurfaceMatchDistance ||
(startDistance === bestSurfaceMatchDistance && (startDistance === bestSurfaceMatchDistance &&
(endDistance < bestSurfaceMatchEndDistance || (endDistance < bestSurfaceMatchEndDistance ||
(endDistance === bestSurfaceMatchEndDistance && candidate.index < bestSurfaceMatchIndex))) (endDistance === bestSurfaceMatchEndDistance &&
candidate.index < bestSurfaceMatchIndex)))
) { ) {
bestSurfaceMatchDistance = startDistance; bestSurfaceMatchDistance = startDistance;
bestSurfaceMatchEndDistance = endDistance; bestSurfaceMatchEndDistance = endDistance;
@@ -274,9 +276,15 @@ function pickClosestMecabPosMetadataByOverlap(
const overlappingTokensByMecabOrder = overlappingTokens const overlappingTokensByMecabOrder = overlappingTokens
.slice() .slice()
.sort((left, right) => left.index - right.index); .sort((left, right) => left.index - right.index);
const overlapPos1 = joinUniqueTags(overlappingTokensByMecabOrder.map((candidate) => candidate.pos1)); const overlapPos1 = joinUniqueTags(
const overlapPos2 = joinUniqueTags(overlappingTokensByMecabOrder.map((candidate) => candidate.pos2)); overlappingTokensByMecabOrder.map((candidate) => candidate.pos1),
const overlapPos3 = joinUniqueTags(overlappingTokensByMecabOrder.map((candidate) => candidate.pos3)); );
const overlapPos2 = joinUniqueTags(
overlappingTokensByMecabOrder.map((candidate) => candidate.pos2),
);
const overlapPos3 = joinUniqueTags(
overlappingTokensByMecabOrder.map((candidate) => candidate.pos3),
);
return { return {
pos1: overlapPos1 ?? bestToken.pos1, pos1: overlapPos1 ?? bestToken.pos1,

View File

@@ -39,7 +39,10 @@ interface YomitanProfileMetadata {
const DEFAULT_YOMITAN_SCAN_LENGTH = 40; const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>(); const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
const yomitanFrequencyCacheByWindow = new WeakMap<BrowserWindow, Map<string, YomitanTermFrequency[]>>(); const yomitanFrequencyCacheByWindow = new WeakMap<
BrowserWindow,
Map<string, YomitanTermFrequency[]>
>();
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object'); return Boolean(value && typeof value === 'object');
@@ -87,7 +90,7 @@ function parsePositiveFrequencyString(value: string): number | null {
const chunks = numericPrefix.split(','); const chunks = numericPrefix.split(',');
const normalizedNumber = const normalizedNumber =
chunks.length <= 1 chunks.length <= 1
? chunks[0] ?? '' ? (chunks[0] ?? '')
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk)) : chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
? chunks.join('') ? chunks.join('')
: (chunks[0] ?? ''); : (chunks[0] ?? '');
@@ -145,11 +148,7 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
: Number.MAX_SAFE_INTEGER; : Number.MAX_SAFE_INTEGER;
const reading = const reading =
value.reading === null value.reading === null ? null : typeof value.reading === 'string' ? value.reading : null;
? null
: typeof value.reading === 'string'
? value.reading
: null;
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null; const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
const displayValueParsed = value.displayValueParsed === true; const displayValueParsed = value.displayValueParsed === true;
@@ -164,7 +163,9 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
}; };
} }
function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): YomitanTermReadingPair[] { function normalizeTermReadingList(
termReadingList: YomitanTermReadingPair[],
): YomitanTermReadingPair[] {
const normalized: YomitanTermReadingPair[] = []; const normalized: YomitanTermReadingPair[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -174,7 +175,9 @@ function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): Yo
continue; continue;
} }
const reading = const reading =
typeof pair.reading === 'string' && pair.reading.trim().length > 0 ? pair.reading.trim() : null; typeof pair.reading === 'string' && pair.reading.trim().length > 0
? pair.reading.trim()
: null;
const key = `${term}\u0000${reading ?? ''}`; const key = `${term}\u0000${reading ?? ''}`;
if (seen.has(key)) { if (seen.has(key)) {
continue; continue;
@@ -298,7 +301,9 @@ function groupFrequencyEntriesByPair(
const grouped = new Map<string, YomitanTermFrequency[]>(); const grouped = new Map<string, YomitanTermFrequency[]>();
for (const entry of entries) { for (const entry of entries) {
const reading = const reading =
typeof entry.reading === 'string' && entry.reading.trim().length > 0 ? entry.reading.trim() : null; typeof entry.reading === 'string' && entry.reading.trim().length > 0
? entry.reading.trim()
: null;
const key = makeTermReadingCacheKey(entry.term.trim(), reading); const key = makeTermReadingCacheKey(entry.term.trim(), reading);
const existing = grouped.get(key); const existing = grouped.get(key);
if (existing) { if (existing) {
@@ -805,7 +810,11 @@ export async function requestYomitanTermFrequencies(
); );
if (fallbackFetchResult !== null) { if (fallbackFetchResult !== null) {
fallbackFetchedEntries = fallbackFetchResult; fallbackFetchedEntries = fallbackFetchResult;
cacheFrequencyEntriesForPairs(frequencyCache, fallbackTermReadingList, fallbackFetchedEntries); cacheFrequencyEntriesForPairs(
frequencyCache,
fallbackTermReadingList,
fallbackFetchedEntries,
);
} }
for (const pair of missingTermReadingList) { for (const pair of missingTermReadingList) {
@@ -829,7 +838,9 @@ export async function requestYomitanTermFrequencies(
[...missingTermReadingList, ...fallbackTermReadingList].map((pair) => pair.term), [...missingTermReadingList, ...fallbackTermReadingList].map((pair) => pair.term),
); );
const cachedResult = buildCachedResult(); const cachedResult = buildCachedResult();
const unmatchedEntries = allFetchedEntries.filter((entry) => !queriedTerms.has(entry.term.trim())); const unmatchedEntries = allFetchedEntries.filter(
(entry) => !queriedTerms.has(entry.term.trim()),
);
return [...cachedResult, ...unmatchedEntries]; return [...cachedResult, ...unmatchedEntries];
} }

View File

@@ -33,7 +33,13 @@ test('sanitizeBackgroundEnv marks background child and keeps warning suppression
test('shouldDetachBackgroundLaunch only for first background invocation', () => { test('shouldDetachBackgroundLaunch only for first background invocation', () => {
assert.equal(shouldDetachBackgroundLaunch(['--background'], {}), true); assert.equal(shouldDetachBackgroundLaunch(['--background'], {}), true);
assert.equal(shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }), false); assert.equal(
assert.equal(shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }), false); shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }),
false,
);
assert.equal(
shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }),
false,
);
assert.equal(shouldDetachBackgroundLaunch(['--start'], {}), false); assert.equal(shouldDetachBackgroundLaunch(['--start'], {}), false);
}); });

View File

@@ -892,7 +892,9 @@ function maybeSignalPluginAutoplayReady(
if (typeof pauseProperty === 'number') { if (typeof pauseProperty === 'number') {
return pauseProperty !== 0; return pauseProperty !== 0;
} }
logger.debug(`[autoplay-ready] unrecognized pause property for media ${mediaPath}: ${String(pauseProperty)}`); logger.debug(
`[autoplay-ready] unrecognized pause property for media ${mediaPath}: ${String(pauseProperty)}`,
);
} catch (error) { } catch (error) {
logger.debug( logger.debug(
`[autoplay-ready] failed to read pause property for media ${mediaPath}: ${(error as Error).message}`, `[autoplay-ready] failed to read pause property for media ${mediaPath}: ${(error as Error).message}`,
@@ -1365,7 +1367,10 @@ function shouldInitializeMecabForAnnotations(): boolean {
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
config.ankiConnect.nPlusOne.highlightEnabled, config.ankiConnect.nPlusOne.highlightEnabled,
); );
const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt); const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
config.subtitleStyle.enableJlpt,
);
const frequencyEnabled = getRuntimeBooleanOption( const frequencyEnabled = getRuntimeBooleanOption(
'subtitle.annotation.frequency', 'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled, config.subtitleStyle.frequencyDictionary.enabled,
@@ -2992,7 +2997,8 @@ const {
showMpvOsd: (text: string) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubtitleDelayToAdjacentCueHandler(direction), shiftSubDelayToAdjacentSubtitle: (direction) =>
shiftSubtitleDelayToAdjacentCueHandler(direction),
sendMpvCommand: (rawCommand: (string | number)[]) => sendMpvCommand: (rawCommand: (string | number)[]) =>
sendMpvCommandRuntime(appState.mpvClient, rawCommand), sendMpvCommandRuntime(appState.mpvClient, rawCommand),
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),

View File

@@ -659,150 +659,144 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
await composed.startTokenizationWarmups(); await composed.startTokenizationWarmups();
}); });
test( test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-ready when dictionary warmup is still pending', async () => {
'composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-ready when dictionary warmup is still pending', const jlptDeferred = createDeferred();
async () => { const frequencyDeferred = createDeferred();
const jlptDeferred = createDeferred(); const osdMessages: string[] = [];
const frequencyDeferred = createDeferred();
const osdMessages: string[] = [];
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ onTokenizationReady?: (text: string) => void }, { onTokenizationReady?: (text: string) => void },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { bindMpvMainEventHandlersMainDeps: {
appState: { appState: {
initialArgs: null, initialArgs: null,
overlayRuntimeInitialized: true, overlayRuntimeInitialized: true,
mpvClient: null, mpvClient: null,
immersionTracker: null, immersionTracker: null,
subtitleTimingTracker: null, subtitleTimingTracker: null,
currentSubText: '', currentSubText: '',
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
}, },
mpvClientRuntimeServiceFactoryMainDeps: { getQuitOnDisconnectArmed: () => false,
createClient: class { scheduleQuitCheck: () => {},
connect(): void {} quitApp: () => {},
on(): void {} reportJellyfinRemoteStopped: () => {},
}, syncOverlayMpvSubtitleSuppression: () => {},
getSocketPath: () => '/tmp/mpv.sock', maybeRunAnilistPostWatchUpdate: async () => {},
getResolvedConfig: () => ({ auto_start_overlay: false }), logSubtitleTimingError: () => {},
isAutoStartOverlayEnabled: () => false, broadcastToOverlayWindows: () => {},
setOverlayVisible: () => {}, onSubtitleChange: () => {},
isVisibleOverlayVisible: () => false, refreshDiscordPresence: () => {},
getReconnectTimer: () => null, ensureImmersionTrackerInitialized: () => {},
setReconnectTimer: () => {}, updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
}, },
updateMpvSubtitleRenderMetricsMainDeps: { getSocketPath: () => '/tmp/mpv.sock',
getCurrentMetrics: () => BASE_METRICS, getResolvedConfig: () => ({ auto_start_overlay: false }),
setCurrentMetrics: () => {}, isAutoStartOverlayEnabled: () => false,
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }), setOverlayVisible: () => {},
broadcastMetrics: () => {}, isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
tokenizer: { createTokenizerRuntimeDeps: (deps) =>
buildTokenizerDepsMainDeps: { deps as unknown as { onTokenizationReady?: (text: string) => void },
getYomitanExt: () => null, tokenizeSubtitle: async (text, deps) => {
getYomitanParserWindow: () => null, deps.onTokenizationReady?.(text);
setYomitanParserWindow: () => {}, return { text };
getYomitanParserReadyPromise: () => null, },
setYomitanParserReadyPromise: () => {}, createMecabTokenizerAndCheckMainDeps: {
getYomitanParserInitPromise: () => null, getMecabTokenizer: () => null,
setYomitanParserInitPromise: () => {}, setMecabTokenizer: () => {},
isKnownWord: () => false, createMecabTokenizer: () => ({ id: 'mecab' }),
recordLookup: () => {}, checkAvailability: async () => {},
getKnownWordMatchMode: () => 'headword', },
getNPlusOneEnabled: () => false, prewarmSubtitleDictionariesMainDeps: {
getMinSentenceWordsForNPlusOne: () => 3, ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
getJlptLevel: () => null, ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
getJlptEnabled: () => true, showMpvOsd: (message) => {
getFrequencyDictionaryEnabled: () => true, osdMessages.push(message);
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: (deps) =>
deps as unknown as { onTokenizationReady?: (text: string) => void },
tokenizeSubtitle: async (text, deps) => {
deps.onTokenizationReady?.(text);
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
showMpvOsd: (message) => {
osdMessages.push(message);
},
}, },
}, },
warmups: { },
launchBackgroundWarmupTaskMainDeps: { warmups: {
now: () => 0, launchBackgroundWarmupTaskMainDeps: {
logDebug: () => {}, now: () => 0,
logWarn: () => {}, logDebug: () => {},
}, logWarn: () => {},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
}, },
}); startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
const warmupPromise = composed.startTokenizationWarmups(); const warmupPromise = composed.startTokenizationWarmups();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, []); assert.deepEqual(osdMessages, []);
await composed.tokenizeSubtitle('first line'); await composed.tokenizeSubtitle('first line');
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']); assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
jlptDeferred.resolve(); jlptDeferred.resolve();
frequencyDeferred.resolve(); frequencyDeferred.resolve();
await warmupPromise; await warmupPromise;
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, [ assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
'Loading subtitle annotations |', });
'Subtitle annotations loaded',
]);
},
);

View File

@@ -10,8 +10,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
showMpvOsd: (text: string) => deps.showMpvOsd(text), showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
playNextSubtitle: () => deps.playNextSubtitle(), playNextSubtitle: () => deps.playNextSubtitle(),
shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction),
deps.shiftSubDelayToAdjacentSubtitle(direction),
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
isMpvConnected: () => deps.isMpvConnected(), isMpvConnected: () => deps.isMpvConnected(),
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(), hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),

View File

@@ -54,7 +54,7 @@ export function createHandleJellyfinListCommands(deps: {
isForced?: boolean; isForced?: boolean;
isExternal?: boolean; isExternal?: boolean;
deliveryUrl?: string | null; deliveryUrl?: string | null;
}> }>
>; >;
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void; writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;

View File

@@ -167,28 +167,22 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']); assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
}); });
test( test('dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', async () => {
'dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', const osdMessages: string[] = [];
async () => {
const osdMessages: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({ const prewarm = createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: async () => undefined, ensureJlptDictionaryLookup: async () => undefined,
ensureFrequencyDictionaryLookup: async () => undefined, ensureFrequencyDictionaryLookup: async () => undefined,
shouldShowOsdNotification: () => false, shouldShowOsdNotification: () => false,
showMpvOsd: (message) => { showMpvOsd: (message) => {
osdMessages.push(message); osdMessages.push(message);
}, },
}); });
await prewarm({ showLoadingOsd: true }); await prewarm({ showLoadingOsd: true });
assert.deepEqual(osdMessages, [ assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
'Loading subtitle annotations |', });
'Subtitle annotations loaded',
]);
},
);
test('dictionary prewarm clears loading OSD timer even if notifications are disabled before completion', async () => { test('dictionary prewarm clears loading OSD timer even if notifications are disabled before completion', async () => {
const clearedTimers: unknown[] = []; const clearedTimers: unknown[] = [];

View File

@@ -139,7 +139,8 @@ export class MecabTokenizer {
); );
this.spawnFn = options.spawnFn ?? childProcess.spawn; this.spawnFn = options.spawnFn ?? childProcess.spawn;
this.execSyncFn = options.execSyncFn ?? childProcess.execSync; this.execSyncFn = options.execSyncFn ?? childProcess.execSync;
this.setTimeoutFn = options.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs)); this.setTimeoutFn =
options.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
this.clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer)); this.clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
} }
@@ -217,11 +218,7 @@ export class MecabTokenizer {
} }
private retryOrResolveRequest(request: MecabQueuedRequest): void { private retryOrResolveRequest(request: MecabQueuedRequest): void {
if ( if (request.retryCount < MecabTokenizer.MAX_RETRY_COUNT && this.enabled && this.available) {
request.retryCount < MecabTokenizer.MAX_RETRY_COUNT &&
this.enabled &&
this.available
) {
this.requestQueue.push({ this.requestQueue.push({
...request, ...request,
retryCount: request.retryCount + 1, retryCount: request.retryCount + 1,
@@ -368,7 +365,9 @@ export class MecabTokenizer {
this.requestQueue = []; this.requestQueue = [];
if (pending.length > 0) { if (pending.length > 0) {
log.warn(`MeCab parser process ended during active work (${reason}); retrying pending request(s).`); log.warn(
`MeCab parser process ended during active work (${reason}); retrying pending request(s).`,
);
for (const request of pending) { for (const request of pending) {
this.retryOrResolveRequest(request); this.retryOrResolveRequest(request);
} }