19 Commits

Author SHA1 Message Date
be4db24861 make pretty 2026-03-02 02:45:51 -08:00
83d21c4b6d fix: narrow fallback frequency filter type predicate 2026-03-02 02:44:07 -08:00
e744fab067 fix: unblock autoplay on tokenization-ready and defer annotation loading 2026-03-02 02:43:09 -08:00
5167e3a494 docs: add plausible tracker config for docs site 2026-03-02 02:33:45 -08:00
aff4e91bbb fix(startup): async dictionary loading and unblock first tokenization
- move JLPT/frequency dictionary init off sync fs APIs and add cooperative yielding during entry processing

- decouple first tokenization from full warmup by gating only on Yomitan readiness while MeCab/dictionary warmups continue in parallel

- update mpv pause-until-ready OSD copy to tokenization-focused wording and refresh gate regression assertions
2026-03-02 01:48:17 -08:00
737101fe9e fix(tokenizer): lazy yomitan term-only frequency fallback 2026-03-02 01:45:37 -08:00
629fe97ef7 chore(tokenizer): align enrichment regression notes and test typing 2026-03-02 01:45:23 -08:00
fa97472bce perf(tokenizer): optimize mecab POS enrichment lookups 2026-03-02 01:39:44 -08:00
83f13df627 perf(tokenizer): skip known-word lookup in MeCab POS enrichment 2026-03-02 01:38:37 -08:00
cde231b1ff fix(tokenizer): avoid repeated yomitan anki sync checks on no-change 2026-03-02 01:36:22 -08:00
7161fc3513 fix: make tokenization warmup one-shot 2026-03-02 01:33:09 -08:00
9a91951656 perf(tokenizer): cut annotation latency with persistent mecab 2026-03-02 01:15:21 -08:00
11e9c721c6 feat(subtitles): add no-jump subtitle-delay shift commands 2026-03-02 01:12:26 -08:00
3c66ea6b30 fix(jellyfin): preserve discover resume position on remote play 2026-03-01 23:28:03 -08:00
79f37f3986 fix(subtitle): prioritize known and n+1 colors over frequency 2026-03-01 23:23:53 -08:00
f1b85b0751 fix(plugin): keep loading OSD visible during startup gate 2026-03-01 23:23:45 -08:00
1ab5d00de0 bump version 2026-03-01 20:12:59 -08:00
17a417e639 fix(subtitle): improve frequency highlight reliability 2026-03-01 20:12:42 -08:00
68e5a7fef3 fix: sanitize jellyfin misc info formatting 2026-03-01 20:05:19 -08:00
77 changed files with 3501 additions and 567 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

@@ -0,0 +1,58 @@
---
id: TASK-81
title: 'Tokenization performance: disable Yomitan MeCab parser, gate local MeCab init, and add persistent MeCab process'
status: Done
assignee: []
created_date: '2026-03-02 07:44'
updated_date: '2026-03-02 20:44'
labels: []
dependencies: []
priority: high
ordinal: 9001
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reduce subtitle annotation latency by:
- disabling Yomitan-side MeCab parser requests (`useMecabParser=false`);
- 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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Yomitan parse requests disable MeCab parser path.
- [x] #2 MeCab warmup/init is skipped when all POS-dependent annotation toggles are off.
- [x] #3 Local MeCab tokenizer uses persistent process across subtitle lines.
- [x] #4 Persistent MeCab process auto-shuts down after idle timeout and restarts on next tokenize activity.
- [x] #5 Tests cover parser flag, warmup gating, and persistent MeCab lifecycle behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented tokenizer latency optimizations:
- switched Yomitan parse requests to `useMecabParser: false`;
- 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 regression tests for Yomitan parse flag, MeCab warmup gating, and persistent/idle lifecycle behavior;
- fixed tokenization warmup gate so first-use warmup completion is sticky (`tokenizationWarmupCompleted`) and sequential `tokenizeSubtitle` calls no longer re-run Yomitan/dictionary warmup path;
- added regression coverage in `src/main/runtime/composers/mpv-runtime-composer.test.ts` for sequential tokenize calls (`warmup` side effects run once);
- post-review critical fix: treat Yomitan default-profile Anki server sync `no-change` as successful check, so `lastSyncedYomitanAnkiServer` is cached and expensive sync checks do not repeat on every subtitle line;
- added regression assertion in `src/core/services/tokenizer/yomitan-parser-runtime.test.ts` for `updated: false` path returning sync success;
- post-review performance fix: refactored POS enrichment to pre-index MeCab tokens by surface plus character-position overlap index, replacing repeated active-candidate filtering/full-scan behavior with direct overlap candidate lookup per token;
- added regression tests in `src/core/services/tokenizer/parser-enrichment-stage.test.ts` for repeated distant-token scan access and repeated active-candidate filter scans; both fail on scan-based behavior and pass with indexed lookup;
- post-review startup fix: moved JLPT/frequency dictionary initialization from synchronous FS APIs to async `fs/promises` path inspection/read and cooperative chunked entry processing to reduce main-thread stall risk during cold start;
- post-review first-line latency fix: decoupled tokenization warmup gating so first `tokenizeSubtitle` only waits on Yomitan extension readiness, while MeCab check + dictionary prewarm continue in parallel background warmups;
- validated with targeted tests and `tsc --noEmit`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,60 @@
---
id: TASK-82
title: 'Subtitle frequency highlighting: fix noisy Yomitan readings and restore known/N+1 color priority'
status: Done
assignee: []
created_date: '2026-03-02 20:10'
updated_date: '2026-03-02 01:44'
labels: []
dependencies: []
priority: high
ordinal: 9002
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Address frequency-highlighting regressions:
- 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.
Expected behavior:
- known/N+1 color always wins;
- if token is frequent and within `topX`, frequency rank label can still appear on hover/metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Frequency lookup succeeds for noisy/truncated merged-token readings via robust fallback behavior.
- [x] #2 Merged-token reading normalization restores missing kana suffixes where safe (`headword === surface` path).
- [x] #3 Known/N+1 tokens keep known/N+1 color classes; frequency color class does not override them.
- [x] #4 Frequency rank hover label remains available for in-range frequent tokens, including known/N+1.
- [x] #5 Regression tests added for tokenizer and renderer behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented and validated:
- 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;
- this removes eager `(term, null)` payload inflation on medium-frequency lines and reduces extension RPC payload/load;
- renderer restored known/N+1 color priority over frequency class coloring;
- frequency rank label display remains available for frequent known/N+1 tokens;
- added regression tests covering noisy-reading fallback, lazy fallback-query behavior, and renderer class/label precedence.
Related commits:
- `17a417e` (`fix(subtitle): improve frequency highlight reliability`)
- `79f37f3` (`fix(subtitle): prioritize known and n+1 colors over frequency`)
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-83
title: 'Jellyfin subtitle delay: shift to adjacent cue without seek jumps'
status: Done
assignee: []
created_date: '2026-03-02 00:06'
updated_date: '2026-03-02 00:06'
labels: []
dependencies: []
priority: high
ordinal: 9003
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
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:
- add special commands for next/previous line alignment;
- compute delta from active subtitle cue timeline (external subtitle file/URL, including Jellyfin-delivered URLs);
- apply `add sub-delay <delta>` and show OSD value;
- keep existing proxy OSD behavior for direct `sub-delay` keybinding commands.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 New special commands exist for subtitle-delay shift to next/previous cue boundary.
- [x] #2 Shift logic parses active external subtitle source timings (SRT/VTT/ASS) and computes delta from current `sub-start`.
- [x] #3 Runtime applies delay shift without `sub-seek` and shows OSD feedback.
- [x] #4 Direct `sub-delay` proxy commands also show OSD current value.
- [x] #5 Tests added for cue parsing/shift behavior and IPC dispatch wiring.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented no-jump subtitle-delay alignment 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`;
- wired command handling through IPC runtime deps into main runtime;
- retained/extended OSD proxy feedback for `sub-delay` keybindings;
- updated configuration docs and added regression tests for subtitle-delay shift and IPC command routing.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -6,6 +6,7 @@
"name": "subminer", "name": "subminer",
"dependencies": { "dependencies": {
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
"@plausible-analytics/tracker": "^0.4.4",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
"discord-rpc": "^4.0.1", "discord-rpc": "^4.0.1",
@@ -188,6 +189,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],

View File

@@ -5,8 +5,25 @@ import '@catppuccin/vitepress/theme/macchiato/mauve.css';
import './mermaid-modal.css'; import './mermaid-modal.css';
let mermaidLoader: Promise<any> | null = null; let mermaidLoader: Promise<any> | null = null;
let plausibleTrackerInitialized = false;
const MERMAID_MODAL_ID = 'mermaid-diagram-modal'; const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
async function initPlausibleTracker() {
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
return;
}
const { init } = await import('@plausible-analytics/tracker');
init({
domain: 'subminer.moe',
endpoint: 'https://worker.subminer.moe',
outboundLinks: true,
fileDownloads: true,
formSubmissions: true,
});
plausibleTrackerInitialized = true;
}
function closeMermaidModal() { function closeMermaidModal() {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
return; return;
@@ -188,7 +205,12 @@ export default {
}); });
}; };
onMounted(render); onMounted(() => {
initPlausibleTracker().catch((error) => {
console.error('Failed to initialize Plausible tracker:', error);
});
render();
});
watch(() => route.path, render); watch(() => route.path, render);
}, },
}; };

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,21 +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 |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | | `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | | `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `KeyQ` | `["quit"]` | Quit mpv | | `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv | | `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
**Custom keybindings example:** **Custom keybindings example:**
@@ -402,11 +405,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null } { "key": "Space", "command": null }
``` ```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value. **Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), SubMiner also shows an mpv OSD notification after the command runs. For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
**See `config.example.jsonc`** for more keybinding examples and configuration options. **See `config.example.jsonc`** for more keybinding examples and configuration options.

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

15
docs/plausible.test.ts Normal file
View File

@@ -0,0 +1,15 @@
import { expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => {
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'");
expect(docsThemeContents).toContain('outboundLinks: true');
expect(docsThemeContents).toContain('fileDownloads: true');
expect(docsThemeContents).toContain('formSubmissions: true');
});

View File

@@ -46,6 +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 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

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.2.1", "version": "0.2.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -58,6 +58,7 @@
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
"@plausible-analytics/tracker": "^0.4.4",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
"discord-rpc": "^4.0.1", "discord-rpc": "^4.0.1",

View File

@@ -3,6 +3,8 @@ local M = {}
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_START_MAX_ATTEMPTS = 6
local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -70,28 +72,50 @@ function M.create(ctx)
state.auto_play_ready_timeout = nil state.auto_play_ready_timeout = nil
end end
local function disarm_auto_play_ready_gate() local function clear_auto_play_ready_osd_timer()
local timer = state.auto_play_ready_osd_timer
if timer and timer.kill then
timer:kill()
end
state.auto_play_ready_osd_timer = nil
end
local function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed
clear_auto_play_ready_timeout() clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
state.auto_play_ready_gate_armed = false state.auto_play_ready_gate_armed = false
if was_armed and should_resume then
mp.set_property_native("pause", false)
end
end end
local function release_auto_play_ready_gate(reason) local function release_auto_play_ready_gate(reason)
if not state.auto_play_ready_gate_armed then if not state.auto_play_ready_gate_armed then
return return
end end
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false) mp.set_property_native("pause", false)
show_osd("Subtitle annotations loaded") show_osd(AUTO_PLAY_READY_READY_OSD)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
end end
local function arm_auto_play_ready_gate() local function arm_auto_play_ready_gate()
if state.auto_play_ready_gate_armed then if state.auto_play_ready_gate_armed then
clear_auto_play_ready_timeout() clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
end end
state.auto_play_ready_gate_armed = true state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true) mp.set_property_native("pause", true)
show_osd("Loading subtitle annotations...") show_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD)
end
end)
end
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal") subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function() state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
if not state.auto_play_ready_gate_armed then if not state.auto_play_ready_gate_armed then
@@ -251,6 +275,23 @@ function M.create(ctx)
if state.overlay_running then if state.overlay_running then
if overrides.auto_start_trigger == true then if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running") subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
local socket_path = overrides.socket_path or opts.socket_path
local should_pause_until_ready = (
resolve_visible_overlay_startup()
and resolve_pause_until_ready()
and has_matching_mpv_ipc_socket(socket_path)
)
if should_pause_until_ready then
arm_auto_play_ready_gate()
else
disarm_auto_play_ready_gate()
end
local visibility_action = resolve_visible_overlay_startup()
and "show-visible-overlay"
or "hide-visible-overlay"
run_control_command_async(visibility_action, {
log_level = overrides.log_level,
})
return return
end end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
@@ -287,7 +328,7 @@ function M.create(ctx)
) )
end end
if attempt == 1 then if attempt == 1 and not state.auto_play_ready_gate_armed then
show_osd("Starting...") show_osd("Starting...")
end end
state.overlay_running = true state.overlay_running = true

View File

@@ -29,6 +29,7 @@ function M.new()
}, },
auto_play_ready_gate_armed = false, auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil, auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
} }
end end

View File

@@ -9,6 +9,7 @@ local function run_plugin_scenario(config)
osd = {}, osd = {},
logs = {}, logs = {},
property_sets = {}, property_sets = {},
periodic_timers = {},
} }
local function make_mp_stub() local function make_mp_stub()
@@ -90,10 +91,32 @@ local function run_plugin_scenario(config)
end end
end end
function mp.add_timeout(_seconds, callback) function mp.add_timeout(seconds, callback)
if callback then local timeout = {
killed = false,
}
function timeout:kill()
self.killed = true
end
local delay = tonumber(seconds) or 0
if callback and delay < 5 then
callback() callback()
end end
return timeout
end
function mp.add_periodic_timer(seconds, callback)
local timer = {
seconds = seconds,
killed = false,
callback = callback,
}
function timer:kill()
self.killed = true
end
recorded.periodic_timers[#recorded.periodic_timers + 1] = timer
return timer
end end
function mp.register_script_message(name, fn) function mp.register_script_message(name, fn)
@@ -281,6 +304,26 @@ local function find_control_call(async_calls, flag)
return nil return nil
end end
local function count_control_calls(async_calls, flag)
local count = 0
for _, call in ipairs(async_calls) do
local args = call.args or {}
local has_flag = false
local has_start = false
for _, value in ipairs(args) do
if value == flag then
has_flag = true
elseif value == "--start" then
has_start = true
end
end
if has_flag and not has_start then
count = count + 1
end
end
return count
end
local function call_has_arg(call, target) local function call_has_arg(call, target)
local args = (call and call.args) or {} local args = (call and call.args) or {}
for _, value in ipairs(args) do for _, value in ipairs(args) do
@@ -352,6 +395,16 @@ local function count_osd_message(messages, target)
return count return count
end end
local function count_property_set(property_sets, name, value)
local count = 0
for _, call in ipairs(property_sets) do
if call.name == name and call.value == value then
count = count + 1
end
end
return count
end
local function fire_event(recorded, name) local function fire_event(recorded, name)
local listeners = recorded.events[name] or {} local listeners = recorded.events[name] or {}
for _, listener in ipairs(listeners) do for _, listener in ipairs(listeners) do
@@ -493,12 +546,64 @@ do
count_start_calls(recorded.async_calls) == 1, count_start_calls(recorded.async_calls) == 1,
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running" "duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
) )
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"duplicate auto-start should re-assert visible overlay state when overlay is already running"
)
assert_true( assert_true(
count_osd_message(recorded.osd, "SubMiner: Already running") == 0, count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
"duplicate auto-start events should not show Already running OSD" "duplicate auto-start events should not show Already running OSD"
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for duplicate auto-start pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_start_calls(recorded.async_calls) == 1,
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"duplicate pause-until-ready auto-start should still re-assert visible overlay state"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2,
"duplicate pause-until-ready auto-start should arm tokenization loading gate for each file"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2,
"duplicate pause-until-ready auto-start should release tokenization gate for each file"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
"duplicate pause-until-ready auto-start should force pause for each file"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2,
"duplicate pause-until-ready auto-start should resume playback for each file"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -528,13 +633,54 @@ do
"autoplay-ready script message should resume mpv playback" "autoplay-ready script message should resume mpv playback"
) )
assert_true( assert_true(
has_osd_message(recorded.osd, "SubMiner: Loading subtitle annotations..."), has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."),
"pause-until-ready auto-start should show loading OSD message" "pause-until-ready auto-start should show loading OSD message"
) )
assert_true( assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle annotations loaded"), not has_osd_message(recorded.osd, "SubMiner: Starting..."),
"pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should show loaded OSD message" "autoplay-ready should show loaded OSD message"
) )
assert_true(
#recorded.periodic_timers == 1,
"pause-until-ready auto-start should create periodic loading OSD refresher"
)
assert_true(
recorded.periodic_timers[1].killed == true,
"autoplay-ready should stop periodic loading OSD refresher"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for pause cleanup scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "end-file")
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 1,
"pause cleanup scenario should force pause while waiting for tokenization"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 1,
"ending file while gate is armed should clear forced pause state"
)
end end
do do

View File

@@ -316,3 +316,33 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">'); assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
assert.equal(merged.ExpressionAudio, merged.SentenceAudio); assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
}); });
test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => {
const integration = new AnkiIntegration(
{
metadata: {
pattern: '[SubMiner] %f (%t)',
},
} as never,
{} as never,
{
currentSubText: '',
currentVideoPath:
'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3',
currentTimePos: 426,
currentSubStart: 426,
currentSubEnd: 428,
currentAudioStreamIndex: 3,
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
send: () => true,
} as unknown as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('api_key='), false);
});

View File

@@ -58,6 +58,55 @@ interface NoteInfo {
type CardKind = 'sentence' | 'audio'; type CardKind = 'sentence' | 'audio';
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function decodeURIComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function extractFilenameFromMediaPath(rawPath: string): string {
const trimmedPath = rawPath.trim();
if (!trimmedPath) return '';
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmedPath)) {
try {
const parsed = new URL(trimmedPath);
return decodeURIComponentSafe(path.basename(parsed.pathname));
} catch {
// Fall through to separator-based handling below.
}
}
const separatorIndex = trimmedPath.search(/[?#]/);
const pathWithoutQuery = separatorIndex >= 0 ? trimmedPath.slice(0, separatorIndex) : trimmedPath;
return decodeURIComponentSafe(path.basename(pathWithoutQuery));
}
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=')) {
return true;
}
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
return true;
}
return (
loweredFilename === 'stream' ||
loweredFilename === 'master.m3u8' ||
loweredFilename === 'index.m3u8' ||
loweredFilename === 'playlist.m3u8'
);
}
export class AnkiIntegration { export class AnkiIntegration {
private client: AnkiConnectClient; private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator; private mediaGenerator: MediaGenerator;
@@ -729,8 +778,12 @@ export class AnkiIntegration {
} }
const currentVideoPath = this.mpvClient.currentVideoPath || ''; const currentVideoPath = this.mpvClient.currentVideoPath || '';
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : ''; const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
const filenameWithExt = videoFilename || fallbackFilename; const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
? mediaTitle || videoFilename
: videoFilename || mediaTitle) || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos = const currentTimePos =

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

@@ -44,6 +44,8 @@ export const SPECIAL_COMMANDS = {
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
} as const; } as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
@@ -56,6 +58,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'ArrowDown', command: ['seek', -60] }, { key: 'ArrowDown', command: ['seek', -60] },
{ key: 'Shift+KeyH', command: ['sub-seek', -1] }, { key: 'Shift+KeyH', command: ['sub-seek', -1] },
{ key: 'Shift+KeyL', command: ['sub-seek', 1] }, { key: 'Shift+KeyL', command: ['sub-seek', 1] },
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] },
{
key: 'Shift+BracketLeft',
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
},
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] }, { key: 'KeyQ', command: ['quit'] },

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

@@ -129,3 +129,39 @@ test('createFrequencyDictionaryLookup parses composite displayValue by primary r
assert.equal(lookup('鍛える'), 3272); assert.equal(lookup('鍛える'), 3272);
assert.equal(lookup('高み'), 9933); assert.equal(lookup('高み'), 9933);
}); });
test('createFrequencyDictionaryLookup does not require synchronous fs APIs', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
fs.writeFileSync(bankPath, JSON.stringify([['猫', 1, { frequency: { displayValue: 42 } }]]));
const readFileSync = fs.readFileSync;
const readdirSync = fs.readdirSync;
const statSync = fs.statSync;
const existsSync = fs.existsSync;
(fs as unknown as Record<string, unknown>).readFileSync = () => {
throw new Error('sync read disabled');
};
(fs as unknown as Record<string, unknown>).readdirSync = () => {
throw new Error('sync readdir disabled');
};
(fs as unknown as Record<string, unknown>).statSync = () => {
throw new Error('sync stat disabled');
};
(fs as unknown as Record<string, unknown>).existsSync = () => {
throw new Error('sync exists disabled');
};
try {
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [tempDir],
log: () => undefined,
});
assert.equal(lookup('猫'), 42);
} finally {
(fs as unknown as Record<string, unknown>).readFileSync = readFileSync;
(fs as unknown as Record<string, unknown>).readdirSync = readdirSync;
(fs as unknown as Record<string, unknown>).statSync = statSync;
(fs as unknown as Record<string, unknown>).existsSync = existsSync;
}
});

View File

@@ -1,4 +1,4 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
export interface FrequencyDictionaryLookupOptions { export interface FrequencyDictionaryLookupOptions {
@@ -13,6 +13,17 @@ interface FrequencyDictionaryEntry {
const FREQUENCY_BANK_FILE_GLOB = /^term_meta_bank_.*\.json$/; const FREQUENCY_BANK_FILE_GLOB = /^term_meta_bank_.*\.json$/;
const NOOP_LOOKUP = (): null => null; const NOOP_LOOKUP = (): null => null;
const ENTRY_YIELD_INTERVAL = 5000;
function isErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === 'object' && (error as { code?: unknown }).code === code);
}
async function yieldToEventLoop(): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function normalizeFrequencyTerm(value: string): string { function normalizeFrequencyTerm(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
@@ -93,16 +104,22 @@ function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry |
}; };
} }
function addEntriesToMap( async function addEntriesToMap(
rawEntries: unknown, rawEntries: unknown,
terms: Map<string, number>, terms: Map<string, number>,
): { duplicateCount: number } { ): Promise<{ duplicateCount: number }> {
if (!Array.isArray(rawEntries)) { if (!Array.isArray(rawEntries)) {
return { duplicateCount: 0 }; return { duplicateCount: 0 };
} }
let duplicateCount = 0; let duplicateCount = 0;
let processedCount = 0;
for (const rawEntry of rawEntries) { for (const rawEntry of rawEntries) {
processedCount += 1;
if (processedCount % ENTRY_YIELD_INTERVAL === 0) {
await yieldToEventLoop();
}
const entry = asFrequencyDictionaryEntry(rawEntry); const entry = asFrequencyDictionaryEntry(rawEntry);
if (!entry) { if (!entry) {
continue; continue;
@@ -119,15 +136,15 @@ function addEntriesToMap(
return { duplicateCount }; return { duplicateCount };
} }
function collectDictionaryFromPath( async function collectDictionaryFromPath(
dictionaryPath: string, dictionaryPath: string,
log: (message: string) => void, log: (message: string) => void,
): Map<string, number> { ): Promise<Map<string, number>> {
const terms = new Map<string, number>(); const terms = new Map<string, number>();
let fileNames: string[]; let fileNames: string[];
try { try {
fileNames = fs.readdirSync(dictionaryPath); fileNames = await fs.readdir(dictionaryPath);
} catch (error) { } catch (error) {
log(`Failed to read frequency dictionary directory ${dictionaryPath}: ${String(error)}`); log(`Failed to read frequency dictionary directory ${dictionaryPath}: ${String(error)}`);
return terms; return terms;
@@ -143,7 +160,7 @@ function collectDictionaryFromPath(
const bankPath = path.join(dictionaryPath, bankFile); const bankPath = path.join(dictionaryPath, bankFile);
let rawText: string; let rawText: string;
try { try {
rawText = fs.readFileSync(bankPath, 'utf-8'); rawText = await fs.readFile(bankPath, 'utf-8');
} catch { } catch {
log(`Failed to read frequency dictionary file ${bankPath}`); log(`Failed to read frequency dictionary file ${bankPath}`);
continue; continue;
@@ -151,6 +168,7 @@ function collectDictionaryFromPath(
let rawEntries: unknown; let rawEntries: unknown;
try { try {
await yieldToEventLoop();
rawEntries = JSON.parse(rawText) as unknown; rawEntries = JSON.parse(rawText) as unknown;
} catch { } catch {
log(`Failed to parse frequency dictionary file as JSON: ${bankPath}`); log(`Failed to parse frequency dictionary file as JSON: ${bankPath}`);
@@ -158,7 +176,7 @@ function collectDictionaryFromPath(
} }
const beforeSize = terms.size; const beforeSize = terms.size;
const { duplicateCount } = addEntriesToMap(rawEntries, terms); const { duplicateCount } = await addEntriesToMap(rawEntries, terms);
if (duplicateCount > 0) { if (duplicateCount > 0) {
log( log(
`Frequency dictionary ignored ${duplicateCount} duplicate term entr${ `Frequency dictionary ignored ${duplicateCount} duplicate term entr${
@@ -185,11 +203,11 @@ export async function createFrequencyDictionaryLookup(
let isDirectory = false; let isDirectory = false;
try { try {
if (!fs.existsSync(dictionaryPath)) { isDirectory = (await fs.stat(dictionaryPath)).isDirectory();
} catch (error) {
if (isErrorCode(error, 'ENOENT')) {
continue; continue;
} }
isDirectory = fs.statSync(dictionaryPath).isDirectory();
} catch (error) {
options.log( options.log(
`Failed to inspect frequency dictionary path ${dictionaryPath}: ${String(error)}`, `Failed to inspect frequency dictionary path ${dictionaryPath}: ${String(error)}`,
); );
@@ -201,7 +219,7 @@ export async function createFrequencyDictionaryLookup(
} }
foundDictionaryPathCount += 1; foundDictionaryPathCount += 1;
const terms = collectDictionaryFromPath(dictionaryPath, options.log); const terms = await collectDictionaryFromPath(dictionaryPath, options.log);
if (terms.size > 0) { if (terms.size > 0) {
options.log(`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`); options.log(`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`);
return (term: string): number | null => { return (term: string): number | null => {

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

@@ -10,6 +10,7 @@ export {
unregisterOverlayShortcutsRuntime, unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut'; } from './overlay-shortcut';
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler'; export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command'; export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
export { export {
copyCurrentSubtitle, copyCurrentSubtitle,

View File

@@ -13,6 +13,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
}, },
triggerSubsyncFromConfig: () => { triggerSubsyncFromConfig: () => {
calls.push('subsync'); calls.push('subsync');
@@ -30,6 +32,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
mpvPlayNextSubtitle: () => { mpvPlayNextSubtitle: () => {
calls.push('next'); calls.push('next');
}, },
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
mpvSendCommand: (command) => { mpvSendCommand: (command) => {
sentCommands.push(command); sentCommands.push(command);
}, },
@@ -68,6 +73,21 @@ test('handleMpvCommandFromIpc emits osd for secondary subtitle track keybinding
assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']); assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']);
}); });
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', () => {
const { options, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
});
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
assert.deepEqual(calls, ['shift:next']);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
const { options, sentCommands, osd } = createOptions({ const { options, sentCommands, osd } = createOptions({
isMpvConnected: () => false, isMpvConnected: () => false,

View File

@@ -12,6 +12,8 @@ export interface HandleMpvCommandFromIpcOptions {
RUNTIME_OPTION_CYCLE_PREFIX: string; RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string; REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string; PLAY_NEXT_SUBTITLE: string;
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
}; };
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -19,6 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void; mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void; mpvPlayNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
mpvSendCommand: (command: (string | number)[]) => void; mpvSendCommand: (command: (string | number)[]) => void;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
hasRuntimeOptionsManager: () => boolean; hasRuntimeOptionsManager: () => boolean;
@@ -46,6 +49,9 @@ function resolveProxyCommandOsd(command: (string | number)[]): string | null {
if (property === 'secondary-sid') { if (property === 'secondary-sid') {
return 'Secondary subtitle track: ${secondary-sid}'; return 'Secondary subtitle track: ${secondary-sid}';
} }
if (property === 'sub-delay') {
return 'Subtitle delay: ${sub-delay}';
}
return null; return null;
} }
@@ -64,6 +70,20 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
) {
const direction =
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START
? 'next'
: 'previous';
options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => {
options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`);
});
return;
}
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (!options.hasRuntimeOptionsManager()) return; if (!options.hasRuntimeOptionsManager()) return;
const [, idToken, directionToken] = first.split(':'); const [, idToken, directionToken] = first.split(':');

View File

@@ -0,0 +1,75 @@
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 { createJlptVocabularyLookup } from './jlpt-vocab';
test('createJlptVocabularyLookup loads JLPT bank entries and resolves known levels', async () => {
const logs: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jlpt-dict-'));
fs.writeFileSync(
path.join(tempDir, 'term_meta_bank_5.json'),
JSON.stringify([
['猫', 1, { frequency: { displayValue: 1 } }],
['犬', 2, { frequency: { displayValue: 2 } }],
]),
);
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_1.json'), JSON.stringify([]));
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_2.json'), JSON.stringify([]));
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_3.json'), JSON.stringify([]));
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_4.json'), JSON.stringify([]));
const lookup = await createJlptVocabularyLookup({
searchPaths: [tempDir],
log: (message) => {
logs.push(message);
},
});
assert.equal(lookup('猫'), 'N5');
assert.equal(lookup('犬'), 'N5');
assert.equal(lookup('鳥'), null);
assert.equal(
logs.some((entry) => entry.includes('JLPT dictionary loaded from')),
true,
);
});
test('createJlptVocabularyLookup does not require synchronous fs APIs', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jlpt-dict-'));
fs.writeFileSync(
path.join(tempDir, 'term_meta_bank_4.json'),
JSON.stringify([['見る', 1, { frequency: { displayValue: 3 } }]]),
);
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_1.json'), JSON.stringify([]));
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_2.json'), JSON.stringify([]));
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_3.json'), JSON.stringify([]));
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_5.json'), JSON.stringify([]));
const readFileSync = fs.readFileSync;
const statSync = fs.statSync;
const existsSync = fs.existsSync;
(fs as unknown as Record<string, unknown>).readFileSync = () => {
throw new Error('sync read disabled');
};
(fs as unknown as Record<string, unknown>).statSync = () => {
throw new Error('sync stat disabled');
};
(fs as unknown as Record<string, unknown>).existsSync = () => {
throw new Error('sync exists disabled');
};
try {
const lookup = await createJlptVocabularyLookup({
searchPaths: [tempDir],
log: () => undefined,
});
assert.equal(lookup('見る'), 'N4');
} finally {
(fs as unknown as Record<string, unknown>).readFileSync = readFileSync;
(fs as unknown as Record<string, unknown>).statSync = statSync;
(fs as unknown as Record<string, unknown>).existsSync = existsSync;
}
});

View File

@@ -1,4 +1,4 @@
import * as fs from 'fs'; import * as fs from 'node:fs/promises';
import * as path from 'path'; import * as path from 'path';
import type { JlptLevel } from '../../types'; import type { JlptLevel } from '../../types';
@@ -24,6 +24,17 @@ const JLPT_LEVEL_PRECEDENCE: Record<JlptLevel, number> = {
}; };
const NOOP_LOOKUP = (): null => null; const NOOP_LOOKUP = (): null => null;
const ENTRY_YIELD_INTERVAL = 5000;
function isErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === 'object' && (error as { code?: unknown }).code === code);
}
async function yieldToEventLoop(): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function normalizeJlptTerm(value: string): string { function normalizeJlptTerm(value: string): string {
return value.trim(); return value.trim();
@@ -36,12 +47,12 @@ function hasFrequencyDisplayValue(meta: unknown): boolean {
return Object.prototype.hasOwnProperty.call(frequency as Record<string, unknown>, 'displayValue'); return Object.prototype.hasOwnProperty.call(frequency as Record<string, unknown>, 'displayValue');
} }
function addEntriesToMap( async function addEntriesToMap(
rawEntries: unknown, rawEntries: unknown,
level: JlptLevel, level: JlptLevel,
terms: Map<string, JlptLevel>, terms: Map<string, JlptLevel>,
log: (message: string) => void, log: (message: string) => void,
): void { ): Promise<void> {
const shouldUpdateLevel = ( const shouldUpdateLevel = (
existingLevel: JlptLevel | undefined, existingLevel: JlptLevel | undefined,
incomingLevel: JlptLevel, incomingLevel: JlptLevel,
@@ -53,7 +64,13 @@ function addEntriesToMap(
return; return;
} }
let processedCount = 0;
for (const rawEntry of rawEntries) { for (const rawEntry of rawEntries) {
processedCount += 1;
if (processedCount % ENTRY_YIELD_INTERVAL === 0) {
await yieldToEventLoop();
}
if (!Array.isArray(rawEntry)) { if (!Array.isArray(rawEntry)) {
continue; continue;
} }
@@ -84,22 +101,31 @@ function addEntriesToMap(
} }
} }
function collectDictionaryFromPath( async function collectDictionaryFromPath(
dictionaryPath: string, dictionaryPath: string,
log: (message: string) => void, log: (message: string) => void,
): Map<string, JlptLevel> { ): Promise<Map<string, JlptLevel>> {
const terms = new Map<string, JlptLevel>(); const terms = new Map<string, JlptLevel>();
for (const bank of JLPT_BANK_FILES) { for (const bank of JLPT_BANK_FILES) {
const bankPath = path.join(dictionaryPath, bank.filename); const bankPath = path.join(dictionaryPath, bank.filename);
if (!fs.existsSync(bankPath)) { try {
log(`JLPT bank file missing for ${bank.level}: ${bankPath}`); if (!(await fs.stat(bankPath)).isFile()) {
log(`JLPT bank file missing for ${bank.level}: ${bankPath}`);
continue;
}
} catch (error) {
if (isErrorCode(error, 'ENOENT')) {
log(`JLPT bank file missing for ${bank.level}: ${bankPath}`);
continue;
}
log(`Failed to inspect JLPT bank file ${bankPath}: ${String(error)}`);
continue; continue;
} }
let rawText: string; let rawText: string;
try { try {
rawText = fs.readFileSync(bankPath, 'utf-8'); rawText = await fs.readFile(bankPath, 'utf-8');
} catch { } catch {
log(`Failed to read JLPT bank file ${bankPath}`); log(`Failed to read JLPT bank file ${bankPath}`);
continue; continue;
@@ -107,6 +133,7 @@ function collectDictionaryFromPath(
let rawEntries: unknown; let rawEntries: unknown;
try { try {
await yieldToEventLoop();
rawEntries = JSON.parse(rawText) as unknown; rawEntries = JSON.parse(rawText) as unknown;
} catch { } catch {
log(`Failed to parse JLPT bank file as JSON: ${bankPath}`); log(`Failed to parse JLPT bank file as JSON: ${bankPath}`);
@@ -119,7 +146,7 @@ function collectDictionaryFromPath(
} }
const beforeSize = terms.size; const beforeSize = terms.size;
addEntriesToMap(rawEntries, bank.level, terms, log); await addEntriesToMap(rawEntries, bank.level, terms, log);
if (terms.size === beforeSize) { if (terms.size === beforeSize) {
log(`JLPT bank file contained no extractable entries: ${bankPath}`); log(`JLPT bank file contained no extractable entries: ${bankPath}`);
} }
@@ -137,17 +164,21 @@ export async function createJlptVocabularyLookup(
const resolvedBanks: string[] = []; const resolvedBanks: string[] = [];
for (const dictionaryPath of options.searchPaths) { for (const dictionaryPath of options.searchPaths) {
attemptedPaths.push(dictionaryPath); attemptedPaths.push(dictionaryPath);
if (!fs.existsSync(dictionaryPath)) { let isDirectory = false;
continue; try {
} isDirectory = (await fs.stat(dictionaryPath)).isDirectory();
} catch (error) {
if (!fs.statSync(dictionaryPath).isDirectory()) { if (isErrorCode(error, 'ENOENT')) {
continue;
}
options.log(`Failed to inspect JLPT dictionary path ${dictionaryPath}: ${String(error)}`);
continue; continue;
} }
if (!isDirectory) continue;
foundDictionaryPathCount += 1; foundDictionaryPathCount += 1;
const terms = collectDictionaryFromPath(dictionaryPath, options.log); const terms = await collectDictionaryFromPath(dictionaryPath, options.log);
if (terms.size > 0) { if (terms.size > 0) {
resolvedBanks.push(dictionaryPath); resolvedBanks.push(dictionaryPath);
foundBankCount += 1; foundBankCount += 1;

View File

@@ -57,6 +57,26 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub
assert.equal(events[0]!.isOverlayVisible, false); assert.equal(events[0]!.isOverlayVisible, false);
}); });
test('MpvIpcClient clears cached media title when media path changes', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
await invokeHandleMessage(client, {
event: 'property-change',
name: 'media-title',
data: '[Jellyfin/direct] Episode 1',
});
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
await invokeHandleMessage(client, {
event: 'property-change',
name: 'path',
data: '/tmp/new-episode.mkv',
});
assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv');
assert.equal(client.currentMediaTitle, null);
});
test('MpvIpcClient parses JSON line protocol in processBuffer', () => { test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const seen: Array<Record<string, unknown>> = []; const seen: Array<Record<string, unknown>> = [];

View File

@@ -134,6 +134,7 @@ export class MpvIpcClient implements MpvClient {
private firstConnection = true; private firstConnection = true;
private hasConnectedOnce = false; private hasConnectedOnce = false;
public currentVideoPath = ''; public currentVideoPath = '';
public currentMediaTitle: string | null = null;
public currentTimePos = 0; public currentTimePos = 0;
public currentSubStart = 0; public currentSubStart = 0;
public currentSubEnd = 0; public currentSubEnd = 0;
@@ -330,6 +331,7 @@ export class MpvIpcClient implements MpvClient {
this.emit('media-path-change', payload); this.emit('media-path-change', payload);
}, },
emitMediaTitleChange: (payload) => { emitMediaTitleChange: (payload) => {
this.currentMediaTitle = payload.title;
this.emit('media-title-change', payload); this.emit('media-title-change', payload);
}, },
emitSubtitleMetricsChange: (patch) => { emitSubtitleMetricsChange: (patch) => {
@@ -364,6 +366,7 @@ export class MpvIpcClient implements MpvClient {
}, },
setCurrentVideoPath: (value: string) => { setCurrentVideoPath: (value: string) => {
this.currentVideoPath = value; this.currentVideoPath = value;
this.currentMediaTitle = null;
}, },
emitSecondarySubtitleVisibility: (payload) => { emitSecondarySubtitleVisibility: (payload) => {
this.emit('secondary-subtitle-visibility', payload); this.emit('secondary-subtitle-visibility', payload);

View File

@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
function createMpvClient(props: Record<string, unknown>) {
return {
connected: true,
requestProperty: async (name: string) => props[name],
};
}
test('shift subtitle delay to next cue using active external srt track', async () => {
const commands: Array<Array<string | number>> = [];
const osd: string[] = [];
let loadCount = 0;
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 2,
external: true,
'external-filename': '/tmp/subs.srt',
},
],
sid: 2,
'sub-start': 3.0,
}),
loadSubtitleSourceText: async () => {
loadCount += 1;
return `1
00:00:01,000 --> 00:00:02,000
line-1
2
00:00:03,000 --> 00:00:04,000
line-2
3
00:00:05,000 --> 00:00:06,000
line-3`;
},
sendMpvCommand: (command) => commands.push(command),
showMpvOsd: (text) => osd.push(text),
});
await handler('next');
await handler('next');
assert.equal(loadCount, 1);
assert.equal(commands.length, 2);
const delta = commands[0]?.[2];
assert.equal(commands[0]?.[0], 'add');
assert.equal(commands[0]?.[1], 'sub-delay');
assert.equal(typeof delta, 'number');
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
});
test('shift subtitle delay to previous cue using active external ass track', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 4,
external: true,
'external-filename': '/tmp/subs.ass',
},
],
sid: 4,
'sub-start': 2.0,
}),
loadSubtitleSourceText: async () => `[Events]
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
sendMpvCommand: (command) => commands.push(command),
showMpvOsd: () => {},
});
await handler('previous');
const delta = commands[0]?.[2];
assert.equal(typeof delta, 'number');
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
});
test('shift subtitle delay throws when no next cue exists', async () => {
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 1,
external: true,
'external-filename': '/tmp/subs.vtt',
},
],
sid: 1,
'sub-start': 5.0,
}),
loadSubtitleSourceText: async () => `WEBVTT
00:00:01.000 --> 00:00:02.000
line-1
00:00:03.000 --> 00:00:04.000
line-2
00:00:05.000 --> 00:00:06.000
line-3`,
sendMpvCommand: () => {},
showMpvOsd: () => {},
});
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
});

View File

@@ -0,0 +1,203 @@
type SubtitleDelayShiftDirection = 'next' | 'previous';
type MpvClientLike = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
external?: unknown;
'external-filename'?: unknown;
};
type SubtitleCueCacheEntry = {
starts: number[];
};
type SubtitleDelayShiftDeps = {
getMpvClient: () => MpvClientLike | null;
loadSubtitleSourceText: (source: string) => Promise<string>;
sendMpvCommand: (command: Array<string | number>) => void;
showMpvOsd: (text: string) => void;
};
function asTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value.trim());
if (Number.isInteger(parsed)) return parsed;
}
return null;
}
function parseSrtOrVttStartTimes(content: string): number[] {
const starts: number[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
);
if (!match) continue;
const hours = Number(match[1] || 0);
const minutes = Number(match[2] || 0);
const seconds = Number(match[3] || 0);
const millis = Number(String(match[4]).padEnd(3, '0'));
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
}
return starts;
}
function parseAssStartTimes(content: string): number[] {
const starts: number[] = [];
const lines = content.split(/\r?\n/);
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},/,
);
if (!match) continue;
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
if (secondsRaw === undefined) continue;
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
const hours = Number(hoursRaw);
const minutes = Number(minutesRaw);
const wholeSeconds = Number(wholeSecondsRaw);
const fraction = Number(`0.${fractionRaw}`);
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
}
return starts;
}
function normalizeCueStarts(starts: number[]): number[] {
const sorted = starts
.filter((value) => Number.isFinite(value) && value >= 0)
.sort((a, b) => a - b);
if (sorted.length === 0) return [];
const deduped: number[] = [sorted[0]!];
for (let i = 1; i < sorted.length; i += 1) {
const current = sorted[i]!;
const previous = deduped[deduped.length - 1]!;
if (Math.abs(current - previous) > 0.0005) {
deduped.push(current);
}
}
return deduped;
}
function parseCueStarts(content: string, source: string): number[] {
const normalizedSource = source.toLowerCase().split('?')[0] || '';
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
const parseAssLike = () => parseAssStartTimes(content);
let starts: number[] = [];
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
starts = parseAssLike();
if (starts.length === 0) {
starts = parseSrtLike();
}
} else {
starts = parseSrtLike();
if (starts.length === 0) {
starts = parseAssLike();
}
}
const normalized = normalizeCueStarts(starts);
if (normalized.length === 0) {
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
}
return normalized;
}
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
const sid = asTrackId(sidRaw);
if (sid === null) {
throw new Error('No active subtitle track selected.');
}
if (!Array.isArray(trackListRaw)) {
throw new Error('Could not inspect subtitle track list.');
}
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
if (!entry || typeof entry !== 'object') return false;
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && asTrackId(track.id) === sid;
});
if (!activeTrack) {
throw new Error('No active subtitle track found in mpv track list.');
}
if (activeTrack.external !== true) {
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
}
const source =
typeof activeTrack['external-filename'] === 'string'
? activeTrack['external-filename'].trim()
: '';
if (!source) {
throw new Error('Active subtitle track has no external subtitle source path.');
}
return source;
}
function findAdjacentCueStart(
starts: number[],
currentStart: number,
direction: SubtitleDelayShiftDirection,
): number {
const epsilon = 0.0005;
if (direction === 'next') {
const target = starts.find((value) => value > currentStart + epsilon);
if (target === undefined) {
throw new Error('No next subtitle cue found for active subtitle source.');
}
return target;
}
for (let index = starts.length - 1; index >= 0; index -= 1) {
const value = starts[index]!;
if (value < currentStart - epsilon) {
return value;
}
}
throw new Error('No previous subtitle cue found for active subtitle source.');
}
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
const cueCache = new Map<string, SubtitleCueCacheEntry>();
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
const client = deps.getMpvClient();
if (!client || !client.connected) {
throw new Error('MPV not connected.');
}
const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('sub-start'),
]);
const currentStart =
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
if (currentStart === null) {
throw new Error('Current subtitle start time is unavailable.');
}
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
let cueStarts = cueCache.get(source)?.starts;
if (!cueStarts) {
const content = await deps.loadSubtitleSourceText(source);
cueStarts = parseCueStarts(content, source);
cueCache.set(source, { starts: cueStarts });
}
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
const delta = targetStart - currentStart;
deps.sendMpvCommand(['add', 'sub-delay', delta]);
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
};
}

View File

@@ -297,6 +297,97 @@ test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in p
assert.equal(result.tokens?.[0]?.frequencyRank, 77); assert.equal(result.tokens?.[0]?.frequencyRank, 77);
}); });
test('tokenizeSubtitle can signal tokenization-ready before enrichment completes', async () => {
const frequencyDeferred = createDeferred<unknown[]>();
const mecabDeferred = createDeferred<null>();
let tokenizationReadyText: string | null = null;
const pendingResult = tokenizeSubtitle(
'猫',
makeDeps({
onTokenizationReady: (text) => {
tokenizationReadyText = text;
},
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return await frequencyDeferred.promise;
}
return [
{
source: 'scanning-parser',
index: 0,
content: [
[
{
text: '猫',
reading: 'ねこ',
headwords: [[{ term: '猫' }]],
},
],
],
},
];
},
},
}) as unknown as Electron.BrowserWindow,
tokenizeWithMecab: async () => {
return await mecabDeferred.promise;
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tokenizationReadyText, '猫');
frequencyDeferred.resolve([]);
mecabDeferred.resolve(null);
await pendingResult;
});
test('tokenizeSubtitle appends trailing kana to merged Yomitan readings when headword equals surface', async () => {
const result = await tokenizeSubtitle(
'断じて見ていない',
makeDeps({
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async () => [
{
source: 'scanning-parser',
index: 0,
content: [
[
{ text: '断', reading: 'だん', headwords: [[{ term: '断じて' }]] },
{ text: 'じて', reading: '', headwords: [[{ term: 'じて' }]] },
],
[
{ text: '見', reading: 'み', headwords: [[{ term: '見る' }]] },
{ text: 'ていない', reading: '', headwords: [[{ term: 'ていない' }]] },
],
],
},
],
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 2);
assert.equal(result.tokens?.[0]?.surface, '断じて');
assert.equal(result.tokens?.[0]?.reading, 'だんじて');
assert.equal(result.tokens?.[1]?.surface, '見ていない');
assert.equal(result.tokens?.[1]?.reading, 'み');
});
test('tokenizeSubtitle queries headword frequencies with token reading for disambiguation', async () => { test('tokenizeSubtitle queries headword frequencies with token reading for disambiguation', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'鍛えた', '鍛えた',
@@ -309,6 +400,11 @@ test('tokenizeSubtitle queries headword frequencies with token reading for disam
webContents: { webContents: {
executeJavaScript: async (script: string) => { executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) { if (script.includes('getTermFrequencies')) {
assert.equal(
script.includes('"term":"鍛える","reading":null'),
false,
'should not eagerly include term-only fallback pair when reading lookup is present',
);
if (!script.includes('"term":"鍛える","reading":"きた"')) { if (!script.includes('"term":"鍛える","reading":"きた"')) {
return []; return [];
} }
@@ -351,6 +447,58 @@ test('tokenizeSubtitle queries headword frequencies with token reading for disam
assert.equal(result.tokens?.[0]?.frequencyRank, 2847); assert.equal(result.tokens?.[0]?.frequencyRank, 2847);
}); });
test('tokenizeSubtitle falls back to term-only Yomitan frequency lookup when reading is noisy', async () => {
const result = await tokenizeSubtitle(
'断じて',
makeDeps({
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
if (!script.includes('"term":"断じて","reading":null')) {
return [];
}
return [
{
term: '断じて',
reading: null,
dictionary: 'freq-dict',
frequency: 7082,
displayValue: '7082',
displayValueParsed: true,
},
];
}
return [
{
source: 'scanning-parser',
index: 0,
content: [
[
{
text: '断じて',
reading: 'だん',
headwords: [[{ term: '断じて' }]],
},
],
],
},
];
},
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, 7082);
});
test('tokenizeSubtitle avoids headword term-only fallback rank when reading-specific frequency exists', async () => { test('tokenizeSubtitle avoids headword term-only fallback rank when reading-specific frequency exists', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'無人', '無人',
@@ -2014,6 +2162,48 @@ test('createTokenizerDepsRuntime checks MeCab availability before first tokenize
assert.equal(second?.[0]?.surface, '仮面'); assert.equal(second?.[0]?.surface, '仮面');
}); });
test('createTokenizerDepsRuntime skips known-word lookup for MeCab POS enrichment tokens', async () => {
let knownWordCalls = 0;
const deps = createTokenizerDepsRuntime({
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => {
knownWordCalls += 1;
return true;
},
getKnownWordMatchMode: () => 'headword',
getJlptLevel: () => null,
getMecabTokenizer: () => ({
tokenize: async () => [
{
word: '仮面',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
pos3: '',
pos4: '',
inflectionType: '',
inflectionForm: '',
headword: '仮面',
katakanaReading: 'カメン',
pronunciation: 'カメン',
},
],
}),
});
const tokens = await deps.tokenizeWithMecab('仮面');
assert.equal(knownWordCalls, 0);
assert.equal(tokens?.[0]?.isKnown, false);
});
test('tokenizeSubtitle uses async MeCab enrichment override when provided', async () => { test('tokenizeSubtitle uses async MeCab enrichment override when provided', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'猫', '猫',
@@ -2180,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

@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
getYomitanGroupDebugEnabled?: () => boolean; getYomitanGroupDebugEnabled?: () => boolean;
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>; tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
enrichTokensWithMecab?: MecabTokenEnrichmentFn; enrichTokensWithMecab?: MecabTokenEnrichmentFn;
onTokenizationReady?: (text: string) => void;
} }
interface MecabTokenizerLike { interface MecabTokenizerLike {
@@ -78,6 +79,7 @@ export interface TokenizerDepsRuntimeOptions {
getMinSentenceWordsForNPlusOne?: () => number; getMinSentenceWordsForNPlusOne?: () => number;
getYomitanGroupDebugEnabled?: () => boolean; getYomitanGroupDebugEnabled?: () => boolean;
getMecabTokenizer: () => MecabTokenizerLike | null; getMecabTokenizer: () => MecabTokenizerLike | null;
onTokenizationReady?: (text: string) => void;
} }
interface TokenizerAnnotationOptions { interface TokenizerAnnotationOptions {
@@ -90,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,
); );
@@ -104,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;
} }
@@ -124,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 {
@@ -183,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),
@@ -211,11 +217,11 @@ export function createTokenizerDepsRuntime(
return null; return null;
} }
const isKnownWordLookup = options.getNPlusOneEnabled?.() === false ? () => false : options.isKnownWord; return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode(), false);
return mergeTokens(rawTokens, isKnownWordLookup, options.getKnownWordMatchMode());
}, },
enrichTokensWithMecab: async (tokens, mecabTokens) => enrichTokensWithMecab: async (tokens, mecabTokens) =>
enrichTokensWithMecabAsync(tokens, mecabTokens), enrichTokensWithMecabAsync(tokens, mecabTokens),
onTokenizationReady: options.onTokenizationReady,
}; };
} }
@@ -249,6 +255,50 @@ function normalizeFrequencyLookupText(rawText: string): string {
return rawText.trim().toLowerCase(); return rawText.trim().toLowerCase();
} }
function isKanaChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (
(code >= 0x3041 && code <= 0x3096) ||
(code >= 0x309b && code <= 0x309f) ||
(code >= 0x30a0 && code <= 0x30fa) ||
(code >= 0x30fd && code <= 0x30ff)
);
}
function getTrailingKanaSuffix(surface: string): string {
const chars = Array.from(surface);
let splitIndex = chars.length;
while (splitIndex > 0 && isKanaChar(chars[splitIndex - 1]!)) {
splitIndex -= 1;
}
if (splitIndex <= 0 || splitIndex >= chars.length) {
return '';
}
return chars.slice(splitIndex).join('');
}
function normalizeYomitanMergedReading(token: MergedToken): string {
const reading = token.reading ?? '';
if (!reading || token.headword !== token.surface) {
return reading;
}
const trailingKanaSuffix = getTrailingKanaSuffix(token.surface);
if (!trailingKanaSuffix || reading.endsWith(trailingKanaSuffix)) {
return reading;
}
return `${reading}${trailingKanaSuffix}`;
}
function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] {
return tokens.map((token) => ({
...token,
reading: normalizeYomitanMergedReading(token),
}));
}
function resolveFrequencyLookupText( function resolveFrequencyLookupText(
token: MergedToken, token: MergedToken,
matchMode: FrequencyDictionaryMatchMode, matchMode: FrequencyDictionaryMatchMode,
@@ -276,17 +326,19 @@ function buildYomitanFrequencyTermReadingList(
tokens: MergedToken[], tokens: MergedToken[],
matchMode: FrequencyDictionaryMatchMode, matchMode: FrequencyDictionaryMatchMode,
): Array<{ term: string; reading: string | null }> { ): Array<{ term: string; reading: string | null }> {
return tokens const termReadingList: Array<{ term: string; reading: string | null }> = [];
.map((token) => { for (const token of tokens) {
const term = resolveFrequencyLookupText(token, matchMode).trim(); const term = resolveFrequencyLookupText(token, matchMode).trim();
if (!term) { if (!term) {
return null; continue;
} }
const readingRaw =
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null; const readingRaw =
return { term, reading: readingRaw }; token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
}) termReadingList.push({ term, reading: readingRaw });
.filter((pair): pair is { term: string; reading: string | null } => pair !== null); }
return termReadingList;
} }
function buildYomitanFrequencyRankMap( function buildYomitanFrequencyRankMap(
@@ -300,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);
@@ -427,19 +480,25 @@ async function parseWithYomitanInternalParser(
if (!selectedTokens || selectedTokens.length === 0) { if (!selectedTokens || selectedTokens.length === 0) {
return null; return null;
} }
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(selectedTokens);
if (deps.getYomitanGroupDebugEnabled?.() === true) { if (deps.getYomitanGroupDebugEnabled?.() === true) {
logSelectedYomitanGroups(text, selectedTokens); logSelectedYomitanGroups(text, normalizedSelectedTokens);
} }
deps.onTokenizationReady?.(text);
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled
? (async () => { ? (async () => {
const frequencyMatchMode = options.frequencyMatchMode; const frequencyMatchMode = options.frequencyMatchMode;
const termReadingList = buildYomitanFrequencyTermReadingList( const termReadingList = buildYomitanFrequencyTermReadingList(
selectedTokens, 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>());
@@ -449,19 +508,19 @@ async function parseWithYomitanInternalParser(
try { try {
const mecabTokens = await deps.tokenizeWithMecab(text); const mecabTokens = await deps.tokenizeWithMecab(text);
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync; const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
return await enrichTokensWithMecab(selectedTokens, mecabTokens); return await enrichTokensWithMecab(normalizedSelectedTokens, mecabTokens);
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
logger.warn( logger.warn(
'Failed to enrich Yomitan tokens with MeCab POS:', 'Failed to enrich Yomitan tokens with MeCab POS:',
error.message, error.message,
`tokenCount=${selectedTokens.length}`, `tokenCount=${normalizedSelectedTokens.length}`,
`textLength=${text.length}`, `textLength=${text.length}`,
); );
return selectedTokens; return normalizedSelectedTokens;
} }
})() })()
: Promise.resolve(selectedTokens); : Promise.resolve(normalizedSelectedTokens);
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([ const [yomitanRankByTerm, enrichedTokens] = await Promise.all([
frequencyRankPromise, frequencyRankPromise,

View File

@@ -48,3 +48,77 @@ test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are n
const emptyResult = enrichTokensWithMecabPos1(tokens, []); const emptyResult = enrichTokensWithMecabPos1(tokens, []);
assert.strictEqual(emptyResult, tokens); assert.strictEqual(emptyResult, tokens);
}); });
test('enrichTokensWithMecabPos1 avoids repeated full scans over distant mecab surfaces', () => {
const tokens = Array.from({ length: 12 }, (_, index) =>
makeToken({ surface: `w${index}`, startPos: index, endPos: index + 1, pos1: '' }),
);
const mecabTokens = tokens.map((token) =>
makeToken({
surface: token.surface,
startPos: token.startPos,
endPos: token.endPos,
pos1: '名詞',
}),
);
let distantSurfaceReads = 0;
const distantToken = makeToken({ surface: '遠', startPos: 500, endPos: 501, pos1: '記号' });
Object.defineProperty(distantToken, 'surface', {
configurable: true,
get() {
distantSurfaceReads += 1;
if (distantSurfaceReads > 3) {
throw new Error('repeated full scan detected');
}
return '遠';
},
});
mecabTokens.push(distantToken);
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
assert.equal(enriched.length, tokens.length);
for (const token of enriched) {
assert.equal(token.pos1, '名詞');
}
});
test('enrichTokensWithMecabPos1 avoids repeated active-candidate filter scans', () => {
const tokens = Array.from({ length: 8 }, (_, index) =>
makeToken({ surface: `u${index}`, startPos: index, endPos: index + 1, pos1: '' }),
);
const mecabTokens = [
makeToken({ surface: 'SENTINEL', startPos: 0, endPos: 100, pos1: '記号' }),
...tokens.map((token, index) =>
makeToken({
surface: `m${index}`,
startPos: token.startPos,
endPos: token.endPos,
pos1: '名詞',
}),
),
];
let sentinelFilterCalls = 0;
const originalFilter = Array.prototype.filter;
Array.prototype.filter = function filterWithSentinelCheck(
this: unknown[],
...args: any[]
): any[] {
const target = this as Array<{ surface?: string }>;
if (target.some((candidate) => candidate?.surface === 'SENTINEL')) {
sentinelFilterCalls += 1;
if (sentinelFilterCalls > 2) {
throw new Error('repeated active candidate filter scan detected');
}
}
return (originalFilter as (...params: any[]) => any[]).apply(this, args);
} as typeof Array.prototype.filter;
try {
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
assert.equal(enriched.length, tokens.length);
} finally {
Array.prototype.filter = originalFilter;
}
});

View File

@@ -6,6 +6,120 @@ type MecabPosMetadata = {
pos3?: string; pos3?: string;
}; };
type IndexedMecabToken = {
index: number;
start: number;
end: number;
surface: string;
pos1: string;
pos2?: string;
pos3?: string;
};
type MecabLookup = {
indexedTokens: IndexedMecabToken[];
byExactSurface: Map<string, IndexedMecabToken[]>;
byTrimmedSurface: Map<string, IndexedMecabToken[]>;
byPosition: Map<number, IndexedMecabToken[]>;
};
function pushMapValue<K, T>(map: Map<K, T[]>, key: K, value: T): void {
const existing = map.get(key);
if (existing) {
existing.push(value);
return;
}
map.set(key, [value]);
}
function toDiscreteSpan(start: number, end: number): { start: number; end: number } {
const discreteStart = Math.floor(start);
const discreteEnd = Math.max(discreteStart + 1, Math.ceil(end));
return {
start: discreteStart,
end: discreteEnd,
};
}
function buildMecabLookup(mecabTokens: MergedToken[]): MecabLookup {
const indexedTokens: IndexedMecabToken[] = [];
for (const [index, token] of mecabTokens.entries()) {
const pos1 = token.pos1;
if (!pos1) {
continue;
}
const surface = token.surface;
const start = token.startPos ?? 0;
const end = token.endPos ?? start + surface.length;
indexedTokens.push({
index,
start,
end,
surface,
pos1,
pos2: token.pos2,
pos3: token.pos3,
});
}
const byExactSurface = new Map<string, IndexedMecabToken[]>();
const byTrimmedSurface = new Map<string, IndexedMecabToken[]>();
const byPosition = new Map<number, IndexedMecabToken[]>();
for (const token of indexedTokens) {
pushMapValue(byExactSurface, token.surface, token);
const trimmedSurface = token.surface.trim();
if (trimmedSurface) {
pushMapValue(byTrimmedSurface, trimmedSurface, token);
}
const discreteSpan = toDiscreteSpan(token.start, token.end);
for (let position = discreteSpan.start; position < discreteSpan.end; position += 1) {
pushMapValue(byPosition, position, token);
}
}
const byStartThenIndexSort = (left: IndexedMecabToken, right: IndexedMecabToken) =>
left.start - right.start || left.index - right.index;
for (const candidates of byExactSurface.values()) {
candidates.sort(byStartThenIndexSort);
}
return {
indexedTokens,
byExactSurface,
byTrimmedSurface,
byPosition,
};
}
function lowerBoundByStart(candidates: IndexedMecabToken[], targetStart: number): number {
let low = 0;
let high = candidates.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (candidates[mid]!.start < targetStart) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
function lowerBoundByIndex(candidates: IndexedMecabToken[], targetIndex: number): number {
let low = 0;
let high = candidates.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (candidates[mid]!.index < targetIndex) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
function joinUniqueTags(values: Array<string | undefined>): string | undefined { function joinUniqueTags(values: Array<string | undefined>): string | undefined {
const unique: string[] = []; const unique: string[] = [];
for (const value of values) { for (const value of values) {
@@ -29,87 +143,129 @@ function joinUniqueTags(values: Array<string | undefined>): string | undefined {
return unique.join('|'); return unique.join('|');
} }
function pickClosestMecabPosMetadata( function pickClosestMecabPosMetadataBySurface(
token: MergedToken, token: MergedToken,
mecabTokens: MergedToken[], candidates: IndexedMecabToken[] | undefined,
): MecabPosMetadata | null { ): MecabPosMetadata | null {
if (mecabTokens.length === 0) { if (!candidates || candidates.length === 0) {
return null; return null;
} }
const tokenStart = token.startPos ?? 0; const tokenStart = token.startPos ?? 0;
const tokenEnd = token.endPos ?? tokenStart + token.surface.length; const tokenEnd = token.endPos ?? tokenStart + token.surface.length;
let bestSurfaceMatchToken: MergedToken | null = null; let bestSurfaceMatchToken: IndexedMecabToken | null = null;
let bestSurfaceMatchDistance = Number.MAX_SAFE_INTEGER; let bestSurfaceMatchDistance = Number.MAX_SAFE_INTEGER;
let bestSurfaceMatchEndDistance = Number.MAX_SAFE_INTEGER; let bestSurfaceMatchEndDistance = Number.MAX_SAFE_INTEGER;
let bestSurfaceMatchIndex = Number.MAX_SAFE_INTEGER;
for (const mecabToken of mecabTokens) { const nearestStartIndex = lowerBoundByStart(candidates, tokenStart);
if (!mecabToken.pos1) { let left = nearestStartIndex - 1;
continue; let right = nearestStartIndex;
while (left >= 0 || right < candidates.length) {
const leftDistance =
left >= 0 ? Math.abs(candidates[left]!.start - tokenStart) : Number.MAX_SAFE_INTEGER;
const rightDistance =
right < candidates.length
? Math.abs(candidates[right]!.start - tokenStart)
: Number.MAX_SAFE_INTEGER;
const nearestDistance = Math.min(leftDistance, rightDistance);
if (nearestDistance > bestSurfaceMatchDistance) {
break;
} }
if (mecabToken.surface !== token.surface) { if (leftDistance === nearestDistance && left >= 0) {
continue; const candidate = candidates[left]!;
const startDistance = Math.abs(candidate.start - tokenStart);
const endDistance = Math.abs(candidate.end - tokenEnd);
if (
startDistance < bestSurfaceMatchDistance ||
(startDistance === bestSurfaceMatchDistance &&
(endDistance < bestSurfaceMatchEndDistance ||
(endDistance === bestSurfaceMatchEndDistance &&
candidate.index < bestSurfaceMatchIndex)))
) {
bestSurfaceMatchDistance = startDistance;
bestSurfaceMatchEndDistance = endDistance;
bestSurfaceMatchIndex = candidate.index;
bestSurfaceMatchToken = candidate;
}
left -= 1;
} }
if (rightDistance === nearestDistance && right < candidates.length) {
const mecabStart = mecabToken.startPos ?? 0; const candidate = candidates[right]!;
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length; const startDistance = Math.abs(candidate.start - tokenStart);
const startDistance = Math.abs(mecabStart - tokenStart); const endDistance = Math.abs(candidate.end - tokenEnd);
const endDistance = Math.abs(mecabEnd - tokenEnd); if (
startDistance < bestSurfaceMatchDistance ||
if ( (startDistance === bestSurfaceMatchDistance &&
startDistance < bestSurfaceMatchDistance || (endDistance < bestSurfaceMatchEndDistance ||
(startDistance === bestSurfaceMatchDistance && endDistance < bestSurfaceMatchEndDistance) (endDistance === bestSurfaceMatchEndDistance &&
) { candidate.index < bestSurfaceMatchIndex)))
bestSurfaceMatchDistance = startDistance; ) {
bestSurfaceMatchEndDistance = endDistance; bestSurfaceMatchDistance = startDistance;
bestSurfaceMatchToken = mecabToken; bestSurfaceMatchEndDistance = endDistance;
bestSurfaceMatchIndex = candidate.index;
bestSurfaceMatchToken = candidate;
}
right += 1;
} }
} }
if (bestSurfaceMatchToken) { if (bestSurfaceMatchToken !== null) {
return { return {
pos1: bestSurfaceMatchToken.pos1 as string, pos1: bestSurfaceMatchToken.pos1,
pos2: bestSurfaceMatchToken.pos2, pos2: bestSurfaceMatchToken.pos2,
pos3: bestSurfaceMatchToken.pos3, pos3: bestSurfaceMatchToken.pos3,
}; };
} }
let bestToken: MergedToken | null = null; return null;
}
function pickClosestMecabPosMetadataByOverlap(
token: MergedToken,
candidates: IndexedMecabToken[],
): MecabPosMetadata | null {
const tokenStart = token.startPos ?? 0;
const tokenEnd = token.endPos ?? tokenStart + token.surface.length;
let bestToken: IndexedMecabToken | null = null;
let bestOverlap = 0; let bestOverlap = 0;
let bestSpan = 0; let bestSpan = 0;
let bestStartDistance = Number.MAX_SAFE_INTEGER; let bestStartDistance = Number.MAX_SAFE_INTEGER;
let bestStart = Number.MAX_SAFE_INTEGER; let bestStart = Number.MAX_SAFE_INTEGER;
const overlappingTokens: MergedToken[] = []; let bestIndex = Number.MAX_SAFE_INTEGER;
const overlappingTokens: IndexedMecabToken[] = [];
for (const mecabToken of mecabTokens) { for (const candidate of candidates) {
if (!mecabToken.pos1) { const mecabStart = candidate.start;
continue; const mecabEnd = candidate.end;
}
const mecabStart = mecabToken.startPos ?? 0;
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
const overlapStart = Math.max(tokenStart, mecabStart); const overlapStart = Math.max(tokenStart, mecabStart);
const overlapEnd = Math.min(tokenEnd, mecabEnd); const overlapEnd = Math.min(tokenEnd, mecabEnd);
const overlap = Math.max(0, overlapEnd - overlapStart); const overlap = Math.max(0, overlapEnd - overlapStart);
if (overlap === 0) { if (overlap === 0) {
continue; continue;
} }
overlappingTokens.push(mecabToken); overlappingTokens.push(candidate);
const span = mecabEnd - mecabStart; const span = mecabEnd - mecabStart;
const startDistance = Math.abs(mecabStart - tokenStart);
if ( if (
overlap > bestOverlap || overlap > bestOverlap ||
(overlap === bestOverlap && (overlap === bestOverlap &&
(Math.abs(mecabStart - tokenStart) < bestStartDistance || (startDistance < bestStartDistance ||
(Math.abs(mecabStart - tokenStart) === bestStartDistance && (startDistance === bestStartDistance &&
(span > bestSpan || (span === bestSpan && mecabStart < bestStart))))) (span > bestSpan ||
(span === bestSpan &&
(mecabStart < bestStart ||
(mecabStart === bestStart && candidate.index < bestIndex)))))))
) { ) {
bestOverlap = overlap; bestOverlap = overlap;
bestSpan = span; bestSpan = span;
bestStartDistance = Math.abs(mecabStart - tokenStart); bestStartDistance = startDistance;
bestStart = mecabStart; bestStart = mecabStart;
bestToken = mecabToken; bestIndex = candidate.index;
bestToken = candidate;
} }
} }
@@ -117,12 +273,21 @@ function pickClosestMecabPosMetadata(
return null; return null;
} }
const overlapPos1 = joinUniqueTags(overlappingTokens.map((token) => token.pos1)); const overlappingTokensByMecabOrder = overlappingTokens
const overlapPos2 = joinUniqueTags(overlappingTokens.map((token) => token.pos2)); .slice()
const overlapPos3 = joinUniqueTags(overlappingTokens.map((token) => token.pos3)); .sort((left, right) => left.index - right.index);
const overlapPos1 = joinUniqueTags(
overlappingTokensByMecabOrder.map((candidate) => candidate.pos1),
);
const overlapPos2 = joinUniqueTags(
overlappingTokensByMecabOrder.map((candidate) => candidate.pos2),
);
const overlapPos3 = joinUniqueTags(
overlappingTokensByMecabOrder.map((candidate) => candidate.pos3),
);
return { return {
pos1: overlapPos1 ?? (bestToken.pos1 as string), pos1: overlapPos1 ?? bestToken.pos1,
pos2: overlapPos2 ?? bestToken.pos2, pos2: overlapPos2 ?? bestToken.pos2,
pos3: overlapPos3 ?? bestToken.pos3, pos3: overlapPos3 ?? bestToken.pos3,
}; };
@@ -130,13 +295,9 @@ function pickClosestMecabPosMetadata(
function fillMissingPos1BySurfaceSequence( function fillMissingPos1BySurfaceSequence(
tokens: MergedToken[], tokens: MergedToken[],
mecabTokens: MergedToken[], byTrimmedSurface: Map<string, IndexedMecabToken[]>,
): MergedToken[] { ): MergedToken[] {
const indexedMecabTokens = mecabTokens if (byTrimmedSurface.size === 0) {
.map((token, index) => ({ token, index }))
.filter(({ token }) => token.pos1 && token.surface.trim().length > 0);
if (indexedMecabTokens.length === 0) {
return tokens; return tokens;
} }
@@ -151,27 +312,13 @@ function fillMissingPos1BySurfaceSequence(
return token; return token;
} }
let best: { token: MergedToken; index: number } | null = null; const candidates = byTrimmedSurface.get(surface);
for (const candidate of indexedMecabTokens) { if (!candidates || candidates.length === 0) {
if (candidate.token.surface !== surface) { return token;
continue;
}
if (candidate.index < cursor) {
continue;
}
best = { token: candidate.token, index: candidate.index };
break;
} }
if (!best) { const atOrAfterCursorIndex = lowerBoundByIndex(candidates, cursor);
for (const candidate of indexedMecabTokens) { const best = candidates[atOrAfterCursorIndex] ?? candidates[0];
if (candidate.token.surface !== surface) {
continue;
}
best = { token: candidate.token, index: candidate.index };
break;
}
}
if (!best) { if (!best) {
return token; return token;
@@ -180,13 +327,41 @@ function fillMissingPos1BySurfaceSequence(
cursor = best.index + 1; cursor = best.index + 1;
return { return {
...token, ...token,
pos1: best.token.pos1, pos1: best.pos1,
pos2: best.token.pos2, pos2: best.pos2,
pos3: best.token.pos3, pos3: best.pos3,
}; };
}); });
} }
function collectOverlapCandidatesByPosition(
token: MergedToken,
byPosition: Map<number, IndexedMecabToken[]>,
): IndexedMecabToken[] {
const tokenStart = token.startPos ?? 0;
const tokenEnd = token.endPos ?? tokenStart + token.surface.length;
const discreteSpan = toDiscreteSpan(tokenStart, tokenEnd);
const seen = new Set<number>();
const overlapCandidates: IndexedMecabToken[] = [];
for (let position = discreteSpan.start; position < discreteSpan.end; position += 1) {
const candidatesAtPosition = byPosition.get(position);
if (!candidatesAtPosition) {
continue;
}
for (const candidate of candidatesAtPosition) {
if (seen.has(candidate.index)) {
continue;
}
seen.add(candidate.index);
overlapCandidates.push(candidate);
}
}
return overlapCandidates;
}
export function enrichTokensWithMecabPos1( export function enrichTokensWithMecabPos1(
tokens: MergedToken[], tokens: MergedToken[],
mecabTokens: MergedToken[] | null, mecabTokens: MergedToken[] | null,
@@ -199,12 +374,36 @@ export function enrichTokensWithMecabPos1(
return tokens; return tokens;
} }
const overlapEnriched = tokens.map((token) => { const lookup = buildMecabLookup(mecabTokens);
if (lookup.indexedTokens.length === 0) {
return tokens;
}
const metadataByTokenIndex = new Map<number, MecabPosMetadata>();
for (const [index, token] of tokens.entries()) {
if (token.pos1) { if (token.pos1) {
return token; continue;
} }
const metadata = pickClosestMecabPosMetadata(token, mecabTokens); const surfaceMetadata = pickClosestMecabPosMetadataBySurface(
token,
lookup.byExactSurface.get(token.surface),
);
if (surfaceMetadata) {
metadataByTokenIndex.set(index, surfaceMetadata);
continue;
}
const overlapCandidates = collectOverlapCandidatesByPosition(token, lookup.byPosition);
const overlapMetadata = pickClosestMecabPosMetadataByOverlap(token, overlapCandidates);
if (overlapMetadata) {
metadataByTokenIndex.set(index, overlapMetadata);
}
}
const overlapEnriched = tokens.map((token, index) => {
const metadata = metadataByTokenIndex.get(index);
if (!metadata) { if (!metadata) {
return token; return token;
} }
@@ -217,5 +416,5 @@ export function enrichTokensWithMecabPos1(
}; };
}); });
return fillMissingPos1BySurfaceSequence(overlapEnriched, mecabTokens); return fillMissingPos1BySurfaceSequence(overlapEnriched, lookup.byTrimmedSurface);
} }

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
requestYomitanParseResults,
requestYomitanTermFrequencies, requestYomitanTermFrequencies,
syncYomitanDefaultAnkiServer, syncYomitanDefaultAnkiServer,
} from './yomitan-parser-runtime'; } from './yomitan-parser-runtime';
@@ -43,15 +44,19 @@ test('syncYomitanDefaultAnkiServer updates default profile server when script re
assert.equal(infoLogs.length, 1); assert.equal(infoLogs.length, 1);
}); });
test('syncYomitanDefaultAnkiServer returns false when script reports no change', async () => { test('syncYomitanDefaultAnkiServer returns true when script reports no change', async () => {
const deps = createDeps(async () => ({ updated: false })); const deps = createDeps(async () => ({ updated: false }));
let infoLogCount = 0;
const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
error: () => undefined, error: () => undefined,
info: () => undefined, info: () => {
infoLogCount += 1;
},
}); });
assert.equal(updated, false); assert.equal(synced, true);
assert.equal(infoLogCount, 0);
}); });
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => { test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
@@ -152,6 +157,102 @@ test('requestYomitanTermFrequencies prefers primary rank from displayValue array
assert.equal(result[0]?.frequency, 7141); assert.equal(result[0]?.frequency, 7141);
}); });
test('requestYomitanTermFrequencies requests term-only fallback only after reading miss', async () => {
const frequencyScripts: string[] = [];
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }],
},
},
],
};
}
if (!script.includes('getTermFrequencies')) {
return [];
}
frequencyScripts.push(script);
if (script.includes('"term":"断じて","reading":"だん"')) {
return [];
}
if (script.includes('"term":"断じて","reading":null')) {
return [
{
term: '断じて',
reading: null,
dictionary: 'freq-dict',
frequency: 7082,
displayValue: '7082',
displayValueParsed: true,
},
];
}
return [];
});
const result = await requestYomitanTermFrequencies([{ term: '断じて', reading: 'だん' }], deps, {
error: () => undefined,
});
assert.equal(result.length, 1);
assert.equal(result[0]?.frequency, 7082);
assert.equal(frequencyScripts.length, 2);
assert.match(frequencyScripts[0] ?? '', /"term":"断じて","reading":"だん"/);
assert.doesNotMatch(frequencyScripts[0] ?? '', /"term":"断じて","reading":null/);
assert.match(frequencyScripts[1] ?? '', /"term":"断じて","reading":null/);
});
test('requestYomitanTermFrequencies avoids term-only fallback request when reading lookup succeeds', async () => {
const frequencyScripts: string[] = [];
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }],
},
},
],
};
}
if (!script.includes('getTermFrequencies')) {
return [];
}
frequencyScripts.push(script);
return [
{
term: '鍛える',
reading: 'きたえる',
dictionary: 'freq-dict',
frequency: 2847,
displayValue: '2847',
displayValueParsed: true,
},
];
});
const result = await requestYomitanTermFrequencies([{ term: '鍛える', reading: 'きた' }], deps, {
error: () => undefined,
});
assert.equal(result.length, 1);
assert.equal(frequencyScripts.length, 1);
assert.match(frequencyScripts[0] ?? '', /"term":"鍛える","reading":"きた"/);
assert.doesNotMatch(frequencyScripts[0] ?? '', /"term":"鍛える","reading":null/);
});
test('requestYomitanTermFrequencies caches profile metadata between calls', async () => { test('requestYomitanTermFrequencies caches profile metadata between calls', async () => {
const scripts: string[] = []; const scripts: string[] = [];
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {
@@ -246,3 +347,32 @@ test('requestYomitanTermFrequencies caches repeated term+reading lookups', async
const frequencyCalls = scripts.filter((script) => script.includes('getTermFrequencies')).length; const frequencyCalls = scripts.filter((script) => script.includes('getTermFrequencies')).length;
assert.equal(frequencyCalls, 1); assert.equal(frequencyCalls, 1);
}); });
test('requestYomitanParseResults disables Yomitan MeCab parser path', async () => {
const scripts: string[] = [];
const deps = createDeps(async (script) => {
scripts.push(script);
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
return [];
});
const result = await requestYomitanParseResults('猫です', deps, {
error: () => undefined,
});
assert.deepEqual(result, []);
const parseScript = scripts.find((script) => script.includes('parseText'));
assert.ok(parseScript, 'expected parseText request script');
assert.match(parseScript ?? '', /useMecabParser:\s*false/);
});

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) {
@@ -529,7 +534,7 @@ export async function requestYomitanParseResults(
optionsContext: { index: ${metadata.profileIndex} }, optionsContext: { index: ${metadata.profileIndex} },
scanLength: ${metadata.scanLength}, scanLength: ${metadata.scanLength},
useInternalParser: true, useInternalParser: true,
useMecabParser: true useMecabParser: false
}); });
})(); })();
` `
@@ -564,7 +569,7 @@ export async function requestYomitanParseResults(
optionsContext: { index: profileIndex }, optionsContext: { index: profileIndex },
scanLength, scanLength,
useInternalParser: true, useInternalParser: true,
useMecabParser: true useMecabParser: false
}); });
})(); })();
`; `;
@@ -578,6 +583,144 @@ export async function requestYomitanParseResults(
} }
} }
async function fetchYomitanTermFrequencies(
parserWindow: BrowserWindow,
termReadingList: YomitanTermReadingPair[],
metadata: YomitanProfileMetadata | null,
logger: LoggerLike,
): Promise<YomitanTermFrequency[] | null> {
if (metadata && metadata.dictionaries.length > 0) {
const script = `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
return await invoke("getTermFrequencies", {
termReadingList: ${JSON.stringify(termReadingList)},
dictionaries: ${JSON.stringify(metadata.dictionaries)}
});
})();
`;
try {
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
return Array.isArray(rawResult)
? normalizeFrequencyEntriesWithPriority(rawResult, metadata.dictionaryPriorityByName)
: [];
} catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message);
return null;
}
}
const script = `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
const optionsFull = await invoke("optionsGetFull", undefined);
const profileIndex = optionsFull.profileCurrent;
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? [];
const dictionaryEntries = Array.isArray(dictionariesRaw)
? dictionariesRaw
.filter((entry) => entry && typeof entry === "object" && entry.enabled === true && typeof entry.name === "string")
.map((entry, index) => ({
name: entry.name,
id: typeof entry.id === "number" && Number.isFinite(entry.id) ? Math.floor(entry.id) : index
}))
.sort((a, b) => a.id - b.id)
: [];
const dictionaries = dictionaryEntries.map((entry) => entry.name);
const dictionaryPriorityByName = dictionaryEntries.reduce((acc, entry, index) => {
acc[entry.name] = index;
return acc;
}, {});
if (dictionaries.length === 0) {
return [];
}
const rawFrequencies = await invoke("getTermFrequencies", {
termReadingList: ${JSON.stringify(termReadingList)},
dictionaries
});
if (!Array.isArray(rawFrequencies)) {
return [];
}
return rawFrequencies
.filter((entry) => entry && typeof entry === "object")
.map((entry) => ({
...entry,
dictionaryPriority:
typeof entry.dictionary === "string" && dictionaryPriorityByName[entry.dictionary] !== undefined
? dictionaryPriorityByName[entry.dictionary]
: Number.MAX_SAFE_INTEGER
}));
})();
`;
try {
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
return Array.isArray(rawResult)
? rawResult
.map((entry) => toYomitanTermFrequency(entry))
.filter((entry): entry is YomitanTermFrequency => entry !== null)
: [];
} catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message);
return null;
}
}
function cacheFrequencyEntriesForPairs(
frequencyCache: Map<string, YomitanTermFrequency[]>,
termReadingList: YomitanTermReadingPair[],
fetchedEntries: YomitanTermFrequency[],
): void {
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
for (const pair of termReadingList) {
const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? [];
frequencyCache.set(key, exactEntries ?? termEntries);
}
}
export async function requestYomitanTermFrequencies( export async function requestYomitanTermFrequencies(
termReadingList: YomitanTermReadingPair[], termReadingList: YomitanTermReadingPair[],
deps: YomitanParserRuntimeDeps, deps: YomitanParserRuntimeDeps,
@@ -622,148 +765,83 @@ export async function requestYomitanTermFrequencies(
return buildCachedResult(); return buildCachedResult();
} }
if (metadata && metadata.dictionaries.length > 0) { const fetchedEntries = await fetchYomitanTermFrequencies(
const script = ` parserWindow,
(async () => { missingTermReadingList,
const invoke = (action, params) => metadata,
new Promise((resolve, reject) => { logger,
chrome.runtime.sendMessage({ action, params }, (response) => { );
if (chrome.runtime.lastError) { if (fetchedEntries === null) {
reject(new Error(chrome.runtime.lastError.message)); return buildCachedResult();
return; }
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
return await invoke("getTermFrequencies", { cacheFrequencyEntriesForPairs(frequencyCache, missingTermReadingList, fetchedEntries);
termReadingList: ${JSON.stringify(missingTermReadingList)},
dictionaries: ${JSON.stringify(metadata.dictionaries)}
});
})();
`;
try { const fallbackTermReadingList = normalizeTermReadingList(
const rawResult = await parserWindow.webContents.executeJavaScript(script, true); missingTermReadingList
const fetchedEntries = Array.isArray(rawResult) .filter((pair) => pair.reading !== null)
? normalizeFrequencyEntriesWithPriority(rawResult, metadata.dictionaryPriorityByName) .map((pair) => {
: [];
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
const missingTerms = new Set(missingTermReadingList.map((pair) => pair.term));
for (const pair of missingTermReadingList) {
const key = makeTermReadingCacheKey(pair.term, pair.reading); const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key); const cachedEntries = frequencyCache.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? []; if (cachedEntries && cachedEntries.length > 0) {
frequencyCache.set(key, exactEntries ?? termEntries); return null;
} }
const cachedResult = buildCachedResult(); const fallbackKey = makeTermReadingCacheKey(pair.term, null);
const unmatchedEntries = fetchedEntries.filter((entry) => !missingTerms.has(entry.term.trim())); const cachedFallback = frequencyCache.get(fallbackKey);
return [...cachedResult, ...unmatchedEntries]; if (cachedFallback && cachedFallback.length > 0) {
} catch (err) { frequencyCache.set(key, cachedFallback);
logger.error('Yomitan term frequency request failed:', (err as Error).message); return null;
}
return { term: pair.term, reading: null };
})
.filter((pair): pair is { term: string; reading: null } => pair !== null),
).filter((pair) => !frequencyCache.has(makeTermReadingCacheKey(pair.term, pair.reading)));
let fallbackFetchedEntries: YomitanTermFrequency[] = [];
if (fallbackTermReadingList.length > 0) {
const fallbackFetchResult = await fetchYomitanTermFrequencies(
parserWindow,
fallbackTermReadingList,
metadata,
logger,
);
if (fallbackFetchResult !== null) {
fallbackFetchedEntries = fallbackFetchResult;
cacheFrequencyEntriesForPairs(
frequencyCache,
fallbackTermReadingList,
fallbackFetchedEntries,
);
} }
return buildCachedResult();
}
const script = `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
const optionsFull = await invoke("optionsGetFull", undefined);
const profileIndex = optionsFull.profileCurrent;
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? [];
const dictionaryEntries = Array.isArray(dictionariesRaw)
? dictionariesRaw
.filter((entry) => entry && typeof entry === "object" && entry.enabled === true && typeof entry.name === "string")
.map((entry, index) => ({
name: entry.name,
id: typeof entry.id === "number" && Number.isFinite(entry.id) ? Math.floor(entry.id) : index
}))
.sort((a, b) => a.id - b.id)
: [];
const dictionaries = dictionaryEntries.map((entry) => entry.name);
const dictionaryPriorityByName = dictionaryEntries.reduce((acc, entry, index) => {
acc[entry.name] = index;
return acc;
}, {});
if (dictionaries.length === 0) {
return [];
}
const rawFrequencies = await invoke("getTermFrequencies", {
termReadingList: ${JSON.stringify(missingTermReadingList)},
dictionaries
});
if (!Array.isArray(rawFrequencies)) {
return [];
}
return rawFrequencies
.filter((entry) => entry && typeof entry === "object")
.map((entry) => ({
...entry,
dictionaryPriority:
typeof entry.dictionary === "string" && dictionaryPriorityByName[entry.dictionary] !== undefined
? dictionaryPriorityByName[entry.dictionary]
: Number.MAX_SAFE_INTEGER
}));
})();
`;
try {
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
const fetchedEntries = Array.isArray(rawResult)
? rawResult
.map((entry) => toYomitanTermFrequency(entry))
.filter((entry): entry is YomitanTermFrequency => entry !== null)
: [];
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
const missingTerms = new Set(missingTermReadingList.map((pair) => pair.term));
for (const pair of missingTermReadingList) { for (const pair of missingTermReadingList) {
if (pair.reading === null) {
continue;
}
const key = makeTermReadingCacheKey(pair.term, pair.reading); const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key); const cachedEntries = frequencyCache.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? []; if (cachedEntries && cachedEntries.length > 0) {
frequencyCache.set(key, exactEntries ?? termEntries); continue;
}
const fallbackEntries = frequencyCache.get(makeTermReadingCacheKey(pair.term, null));
if (fallbackEntries && fallbackEntries.length > 0) {
frequencyCache.set(key, fallbackEntries);
}
} }
const cachedResult = buildCachedResult();
const unmatchedEntries = fetchedEntries.filter((entry) => !missingTerms.has(entry.term.trim()));
return [...cachedResult, ...unmatchedEntries];
} catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message);
return buildCachedResult();
} }
const allFetchedEntries = [...fetchedEntries, ...fallbackFetchedEntries];
const queriedTerms = new Set(
[...missingTermReadingList, ...fallbackTermReadingList].map((pair) => pair.term),
);
const cachedResult = buildCachedResult();
const unmatchedEntries = allFetchedEntries.filter(
(entry) => !queriedTerms.has(entry.term.trim()),
);
return [...cachedResult, ...unmatchedEntries];
} }
export async function syncYomitanDefaultAnkiServer( export async function syncYomitanDefaultAnkiServer(
@@ -846,7 +924,11 @@ export async function syncYomitanDefaultAnkiServer(
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`); logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
return true; return true;
} }
return false; const checkedWithoutUpdate =
typeof result === 'object' &&
result !== null &&
(result as { updated?: unknown }).updated === false;
return checkedWithoutUpdate;
} catch (err) { } catch (err) {
logger.error('Failed to sync Yomitan default profile Anki server:', (err as Error).message); logger.error('Failed to sync Yomitan default profile Anki server:', (err as Error).message);
return false; return false;

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

@@ -331,6 +331,7 @@ import {
copyCurrentSubtitle as copyCurrentSubtitleCore, copyCurrentSubtitle as copyCurrentSubtitleCore,
createConfigHotReloadRuntime, createConfigHotReloadRuntime,
createDiscordPresenceService, createDiscordPresenceService,
createShiftSubtitleDelayToAdjacentCueHandler,
createFieldGroupingOverlayRuntime, createFieldGroupingOverlayRuntime,
createOverlayContentMeasurementStore, createOverlayContentMeasurementStore,
createOverlayManager, createOverlayManager,
@@ -853,21 +854,30 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH
let autoPlayReadySignalMediaPath: string | null = null; let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0; let autoPlayReadySignalGeneration = 0;
function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { function maybeSignalPluginAutoplayReady(
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
): void {
if (!payload.text.trim()) { if (!payload.text.trim()) {
return; return;
} }
const mediaPath = appState.currentMediaPath; const mediaPath =
if (!mediaPath) { appState.currentMediaPath?.trim() ||
return; appState.mpvClient?.currentVideoPath?.trim() ||
} '__unknown__';
if (autoPlayReadySignalMediaPath === mediaPath) { const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const allowDuplicateWhilePaused =
options?.forceWhilePaused === true && appState.playbackPaused !== false;
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
return; return;
} }
autoPlayReadySignalMediaPath = mediaPath; autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration; const playbackGeneration = ++autoPlayReadySignalGeneration;
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`); const signalPluginAutoplayReady = (): void => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
};
signalPluginAutoplayReady();
const isPlaybackPaused = async (client: { const isPlaybackPaused = async (client: {
requestProperty: (property: string) => Promise<unknown>; requestProperty: (property: string) => Promise<unknown>;
}): Promise<boolean> => { }): Promise<boolean> => {
@@ -882,7 +892,9 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
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}`,
@@ -891,55 +903,52 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
return true; return true;
}; };
// Fallback: unpause directly in case plugin readiness handler is unavailable/outdated. // Fallback: repeatedly try to release pause for a short window in case startup
void (async () => { // gate arming and tokenization-ready signal arrive out of order.
const mpvClient = appState.mpvClient; const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3;
if (!mpvClient?.connected) { const releaseRetryDelayMs = 200;
logger.debug('[autoplay-ready] skipped unpause fallback; mpv not connected'); const attemptRelease = (attempt: number): void => {
return; void (async () => {
} if (
autoPlayReadySignalMediaPath !== mediaPath ||
playbackGeneration !== autoPlayReadySignalGeneration
) {
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient); const mpvClient = appState.mpvClient;
logger.debug(`[autoplay-ready] mpv paused before fallback for ${mediaPath}: ${shouldUnpause}`); if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
if (!shouldUnpause) { setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed');
return;
}
mpvClient.send({ command: ['set_property', 'pause', false] });
setTimeout(() => {
void (async () => {
if (
autoPlayReadySignalMediaPath !== mediaPath ||
playbackGeneration !== autoPlayReadySignalGeneration
) {
return;
} }
return;
}
const followupClient = appState.mpvClient; const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!followupClient?.connected) { logger.debug(
return; `[autoplay-ready] mpv paused before fallback attempt ${attempt} for ${mediaPath}: ${shouldUnpause}`,
);
if (!shouldUnpause) {
if (attempt === 0) {
logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed');
} }
return;
}
const shouldUnpauseFollowup = await isPlaybackPaused(followupClient); signalPluginAutoplayReady();
if (!shouldUnpauseFollowup) { mpvClient.send({ command: ['set_property', 'pause', false] });
return; if (attempt < maxReleaseAttempts) {
} setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
followupClient.send({ command: ['set_property', 'pause', false] }); }
})(); })();
}, 500); };
logger.debug('[autoplay-ready] issued direct mpv unpause fallback'); attemptRelease(0);
})();
} }
let appTray: Tray | null = null; let appTray: Tray | null = null;
const buildSubtitleProcessingControllerMainDepsHandler = const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({ createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text: string) => { tokenizeSubtitle: async (text: string) => {
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
return null;
}
return await tokenizeSubtitle(text); return await tokenizeSubtitle(text);
}, },
emitSubtitle: (payload) => { emitSubtitle: (payload) => {
@@ -950,7 +959,6 @@ const buildSubtitleProcessingControllerMainDepsHandler =
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}); });
maybeSignalPluginAutoplayReady(payload);
}, },
logDebug: (message) => { logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`); logger.debug(`[subtitle-processing] ${message}`);
@@ -1353,6 +1361,23 @@ function getRuntimeBooleanOption(
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
function shouldInitializeMecabForAnnotations(): boolean {
const config = getResolvedConfig();
const nPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.nPlusOne.highlightEnabled,
);
const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
config.subtitleStyle.enableJlpt,
);
const frequencyEnabled = getRuntimeBooleanOption(
'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled,
);
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
}
const { const {
getResolvedJellyfinConfig, getResolvedJellyfinConfig,
getJellyfinClientInfo, getJellyfinClientInfo,
@@ -2320,9 +2345,7 @@ const {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
if (appState.currentMediaPath !== path) { autoPlayReadySignalMediaPath = null;
autoPlayReadySignalMediaPath = null;
}
if (path) { if (path) {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
} }
@@ -2428,6 +2451,9 @@ const {
getFrequencyRank: (text) => appState.frequencyRankLookup(text), getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
onTokenizationReady: (text) => {
maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true });
},
}, },
createTokenizerRuntimeDeps: (deps) => createTokenizerRuntimeDeps: (deps) =>
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]), createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
@@ -2469,7 +2495,10 @@ const {
if (startupWarmups.lowPowerMode) { if (startupWarmups.lowPowerMode) {
return false; return false;
} }
return startupWarmups.mecab; if (!startupWarmups.mecab) {
return false;
}
return shouldInitializeMecabForAnnotations();
}, },
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension, shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
shouldWarmupSubtitleDictionaries: () => { shouldWarmupSubtitleDictionaries: () => {
@@ -2609,7 +2638,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
return; return;
} }
const updated = await syncYomitanDefaultAnkiServerCore( const synced = await syncYomitanDefaultAnkiServerCore(
targetUrl, targetUrl,
{ {
getYomitanExt: () => appState.yomitanExt, getYomitanExt: () => appState.yomitanExt,
@@ -2636,8 +2665,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
}, },
); );
if (updated) { if (synced) {
logger.info(`Yomitan default profile Anki server set to ${targetUrl}`);
lastSyncedYomitanAnkiServer = targetUrl; lastSyncedYomitanAnkiServer = targetUrl;
} }
} }
@@ -2925,6 +2953,30 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
appendClipboardVideoToQueueMainDeps, appendClipboardVideoToQueueMainDeps,
); );
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () => appState.mpvClient,
loadSubtitleSourceText: async (source) => {
if (/^https?:\/\//i.test(source)) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(source, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to download subtitle source (${response.status})`);
}
return await response.text();
} finally {
clearTimeout(timeoutId);
}
}
const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source;
return fs.promises.readFile(filePath, 'utf8');
},
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
showMpvOsd: (text) => showMpvOsd(text),
});
const { const {
handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler, handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler,
runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler, runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler,
@@ -2945,6 +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),
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

@@ -180,6 +180,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand']; mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected']; isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager']; hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
@@ -328,6 +329,7 @@ export function createMpvCommandRuntimeServiceDeps(
showMpvOsd: params.showMpvOsd, showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle, mpvReplaySubtitle: params.mpvReplaySubtitle,
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
mpvSendCommand: params.mpvSendCommand, mpvSendCommand: params.mpvSendCommand,
isMpvConnected: params.isMpvConnected, isMpvConnected: params.isMpvConnected,
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager, hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,

View File

@@ -10,6 +10,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
playNextSubtitle: () => void; playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
hasRuntimeOptionsManager: () => boolean; hasRuntimeOptionsManager: () => boolean;
@@ -29,6 +30,8 @@ export function handleMpvCommandFromIpcRuntime(
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvReplaySubtitle: deps.replayCurrentSubtitle,
mpvPlayNextSubtitle: deps.playNextSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle,
shiftSubDelayToAdjacentSubtitle: (direction) =>
deps.shiftSubDelayToAdjacentSubtitle(direction),
mpvSendCommand: deps.sendMpvCommand, mpvSendCommand: deps.sendMpvCommand,
isMpvConnected: deps.isMpvConnected, isMpvConnected: deps.isMpvConnected,
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager, hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,

View File

@@ -14,6 +14,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
playNextSubtitle: () => {}, playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
isMpvConnected: () => false, isMpvConnected: () => false,
hasRuntimeOptionsManager: () => true, hasRuntimeOptionsManager: () => true,

View File

@@ -22,6 +22,14 @@ const BASE_METRICS: MpvSubtitleRenderMetrics = {
osdDimensions: null, osdDimensions: null,
}; };
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = []; const calls: string[] = [];
let started = false; let started = false;
@@ -236,3 +244,559 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.ok(calls.includes('warmup-yomitan')); assert.ok(calls.includes('warmup-yomitan'));
assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true')); assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true'));
}); });
test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annotations are disabled', async () => {
const calls: string[] = [];
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {}
on(): void {}
connect(): void {
this.connected = true;
}
}
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: 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: {
createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
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: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
mecabTokenizer = next as { id: string };
calls.push('set-mecab');
},
createMecabTokenizer: () => {
calls.push('create-mecab');
return { id: 'mecab' };
},
checkAvailability: async () => {
calls.push('check-mecab');
},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
await composed.startTokenizationWarmups();
assert.deepEqual(calls, []);
});
test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential tokenize calls', async () => {
let yomitanWarmupCalls = 0;
let prewarmJlptCalls = 0;
let prewarmFrequencyCalls = 0;
const tokenizeCalls: string[] = [];
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: 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: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
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: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => {
tokenizeCalls.push(text);
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {
prewarmJlptCalls += 1;
},
ensureFrequencyDictionaryLookup: async () => {
prewarmFrequencyCalls += 1;
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1;
},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
await composed.tokenizeSubtitle('first');
await composed.tokenizeSubtitle('second');
assert.deepEqual(tokenizeCalls, ['first', 'second']);
assert.equal(yomitanWarmupCalls, 1);
assert.equal(prewarmJlptCalls, 0);
assert.equal(prewarmFrequencyCalls, 0);
});
test('composeMpvRuntimeHandlers does not block first tokenization on dictionary or MeCab warmup', async () => {
const jlptDeferred = createDeferred();
const frequencyDeferred = createDeferred();
const mecabDeferred = createDeferred();
let tokenizeResolved = false;
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: 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: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
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: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => mecabDeferred.promise,
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
const tokenizePromise = composed.tokenizeSubtitle('first line').then(() => {
tokenizeResolved = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(tokenizeResolved, true);
jlptDeferred.resolve();
frequencyDeferred.resolve();
mecabDeferred.resolve();
await tokenizePromise;
await composed.startTokenizationWarmups();
});
test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-ready when dictionary warmup is still pending', async () => {
const jlptDeferred = createDeferred();
const frequencyDeferred = createDeferred();
const osdMessages: string[] = [];
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ onTokenizationReady?: (text: string) => void },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: 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: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
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,
},
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: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
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();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, []);
await composed.tokenizeSubtitle('first line');
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
jlptDeferred.resolve();
frequencyDeferred.resolve();
await warmupPromise;
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
});

View File

@@ -133,15 +133,58 @@ export function composeMpvRuntimeHandlers<
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler( const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
options.tokenizer.prewarmSubtitleDictionariesMainDeps, options.tokenizer.prewarmSubtitleDictionariesMainDeps,
); );
const shouldInitializeMecabForAnnotations = (): boolean => {
const nPlusOneEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
const frequencyEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
};
const shouldWarmupAnnotationDictionaries = (): boolean => {
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
const frequencyEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
return jlptEnabled || frequencyEnabled;
};
let tokenizationWarmupInFlight: Promise<void> | null = null; let tokenizationWarmupInFlight: Promise<void> | null = null;
let tokenizationPrerequisiteWarmupInFlight: Promise<void> | null = null;
let tokenizationPrerequisiteWarmupCompleted = false;
let tokenizationWarmupCompleted = false;
const ensureTokenizationPrerequisites = (): Promise<void> => {
if (tokenizationPrerequisiteWarmupCompleted) {
return Promise.resolve();
}
if (!tokenizationPrerequisiteWarmupInFlight) {
tokenizationPrerequisiteWarmupInFlight = options.warmups.startBackgroundWarmupsMainDeps
.ensureYomitanExtensionLoaded()
.then(() => {
tokenizationPrerequisiteWarmupCompleted = true;
})
.finally(() => {
tokenizationPrerequisiteWarmupInFlight = null;
});
}
return tokenizationPrerequisiteWarmupInFlight;
};
const startTokenizationWarmups = (): Promise<void> => { const startTokenizationWarmups = (): Promise<void> => {
if (tokenizationWarmupCompleted) {
return Promise.resolve();
}
if (!tokenizationWarmupInFlight) { if (!tokenizationWarmupInFlight) {
tokenizationWarmupInFlight = (async () => { tokenizationWarmupInFlight = (async () => {
await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded(); const warmupTasks: Promise<unknown>[] = [ensureTokenizationPrerequisites()];
if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) { if (
await createMecabTokenizerAndCheck().catch(() => {}); shouldInitializeMecabForAnnotations() &&
!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()
) {
warmupTasks.push(createMecabTokenizerAndCheck().catch(() => {}));
} }
await prewarmSubtitleDictionaries({ showLoadingOsd: true }); if (shouldWarmupAnnotationDictionaries()) {
warmupTasks.push(prewarmSubtitleDictionaries().catch(() => {}));
}
await Promise.all(warmupTasks);
tokenizationWarmupCompleted = true;
})().finally(() => { })().finally(() => {
tokenizationWarmupInFlight = null; tokenizationWarmupInFlight = null;
}); });
@@ -149,10 +192,21 @@ export function composeMpvRuntimeHandlers<
return tokenizationWarmupInFlight; return tokenizationWarmupInFlight;
}; };
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => { const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
await startTokenizationWarmups(); if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
await ensureTokenizationPrerequisites();
const tokenizerMainDeps = buildTokenizerDepsHandler();
if (shouldWarmupAnnotationDictionaries()) {
const onTokenizationReady = tokenizerMainDeps.onTokenizationReady;
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
onTokenizationReady?.(tokenizedText);
if (!tokenizationWarmupCompleted) {
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
}
};
}
return options.tokenizer.tokenizeSubtitle( return options.tokenizer.tokenizeSubtitle(
text, text,
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()), options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps),
); );
}; };

View File

@@ -17,6 +17,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
playNextSubtitle: () => {}, playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
isMpvConnected: () => true, isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true, hasRuntimeOptionsManager: () => true,

View File

@@ -14,6 +14,7 @@ test('handle mpv command handler forwards command and built deps', () => {
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
playNextSubtitle: () => {}, playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
isMpvConnected: () => true, isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true, hasRuntimeOptionsManager: () => true,

View File

@@ -11,6 +11,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'), replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('next'), playNextSubtitle: () => calls.push('next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`), sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
isMpvConnected: () => true, isMpvConnected: () => true,
hasRuntimeOptionsManager: () => false, hasRuntimeOptionsManager: () => false,
@@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.showMpvOsd('hello'); deps.showMpvOsd('hello');
deps.replayCurrentSubtitle(); deps.replayCurrentSubtitle();
deps.playNextSubtitle(); deps.playNextSubtitle();
void deps.shiftSubDelayToAdjacentSubtitle('next');
deps.sendMpvCommand(['show-text', 'ok']); deps.sendMpvCommand(['show-text', 'ok']);
assert.equal(deps.isMpvConnected(), true); assert.equal(deps.isMpvConnected(), true);
assert.equal(deps.hasRuntimeOptionsManager(), false); assert.equal(deps.hasRuntimeOptionsManager(), false);
@@ -31,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'osd:hello', 'osd:hello',
'replay', 'replay',
'next', 'next',
'shift:next',
'cmd:show-text:ok', 'cmd:show-text:ok',
]); ]);
}); });

View File

@@ -10,6 +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) => 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

@@ -107,3 +107,43 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start'); assert.equal(reportPayloads[0]?.eventName, 'start');
}); });
test('playback handler applies start override to stream url for remote resume', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8?api_key=token',
mode: 'transcode',
title: 'Episode 2',
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-2',
startTimeTicksOverride: 55_000_000,
});
assert.equal(commands[1]?.[0], 'loadfile');
const loadedUrl = String(commands[1]?.[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
});

View File

@@ -16,6 +16,21 @@ type ActivePlaybackState = {
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
}; };
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
if (typeof startTimeTicksOverride !== 'number') return url;
try {
const resolved = new URL(url);
if (startTimeTicksOverride > 0) {
resolved.searchParams.set('StartTimeTicks', String(Math.max(0, startTimeTicksOverride)));
} else {
resolved.searchParams.delete('StartTimeTicks');
}
return resolved.toString();
} catch {
return url;
}
}
export function createPlayJellyfinItemInMpvHandler(deps: { export function createPlayJellyfinItemInMpvHandler(deps: {
ensureMpvConnectedForPlayback: () => Promise<boolean>; ensureMpvConnectedForPlayback: () => Promise<boolean>;
getMpvClient: () => MpvRuntimeClientLike | null; getMpvClient: () => MpvRuntimeClientLike | null;
@@ -78,7 +93,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient); deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
deps.sendMpvCommand(['loadfile', plan.url, 'replace']); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) { if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect(); deps.armQuitOnDisconnect();
} }

View File

@@ -52,6 +52,34 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]); assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]);
}); });
test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => {
const calls: Array<{ itemId: string; start?: number }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-2'],
StartPositionTicks: '12345',
});
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345 }]);
});
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => { test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
const warnings: string[] = []; const warnings: string[] = [];
const handlePlay = createHandleJellyfinRemotePlay({ const handlePlay = createHandleJellyfinRemotePlay({

View File

@@ -27,8 +27,12 @@ type JellyfinConfigLike = {
}; };
function asInteger(value: unknown): number | undefined { function asInteger(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined; if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
return value; if (typeof value === 'string') {
const parsed = Number(value.trim());
if (Number.isSafeInteger(parsed)) return parsed;
}
return undefined;
} }
export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null { export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null {

View File

@@ -167,7 +167,7 @@ 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('dictionary prewarm does not show OSD when notifications are disabled', async () => { test('dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', async () => {
const osdMessages: string[] = []; const osdMessages: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({ const prewarm = createPrewarmSubtitleDictionariesMainHandler({
@@ -181,7 +181,7 @@ test('dictionary prewarm does not show OSD when notifications are disabled', asy
await prewarm({ showLoadingOsd: true }); await prewarm({ showLoadingOsd: true });
assert.deepEqual(osdMessages, []); assert.deepEqual(osdMessages, ['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 () => {

View File

@@ -48,6 +48,7 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getFrequencyRank: (text: string) => deps.getFrequencyRank(text), getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(), getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
getMecabTokenizer: () => deps.getMecabTokenizer(), getMecabTokenizer: () => deps.getMecabTokenizer(),
onTokenizationReady: (text: string) => deps.onTokenizationReady?.(text),
}); });
} }
@@ -81,7 +82,6 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
let loadingOsdFrame = 0; let loadingOsdFrame = 0;
let loadingOsdTimer: unknown = null; let loadingOsdTimer: unknown = null;
const showMpvOsd = deps.showMpvOsd; const showMpvOsd = deps.showMpvOsd;
const shouldShowOsdNotification = deps.shouldShowOsdNotification ?? (() => false);
const setIntervalHandler = const setIntervalHandler =
deps.setInterval ?? deps.setInterval ??
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs)); ((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
@@ -91,7 +91,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
const spinnerFrames = ['|', '/', '-', '\\']; const spinnerFrames = ['|', '/', '-', '\\'];
const beginLoadingOsd = (): boolean => { const beginLoadingOsd = (): boolean => {
if (!showMpvOsd || !shouldShowOsdNotification()) { if (!showMpvOsd) {
return false; return false;
} }
loadingOsdDepth += 1; loadingOsdDepth += 1;

114
src/mecab-tokenizer.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import * as childProcess from 'node:child_process';
import { PassThrough, Writable } from 'node:stream';
import { MecabTokenizer } from './mecab-tokenizer';
function createFakeMecabProcess(onKill: () => void): ReturnType<typeof childProcess.spawn> {
const stdout = new PassThrough();
const stderr = new PassThrough();
const stdin = new Writable({
write(chunk, _encoding, callback) {
const text = String(chunk).replace(/\n+$/, '').trim();
if (!text) {
stdout.write('EOS\n');
callback();
return;
}
const payload = `${text}\t名詞,一般,*,*,*,*,${text},${text},${text}\nEOS\n`;
stdout.write(payload);
callback();
},
});
const process = new EventEmitter() as unknown as ReturnType<typeof childProcess.spawn> & {
stdin: Writable;
stdout: PassThrough;
stderr: PassThrough;
};
process.stdin = stdin;
process.stdout = stdout;
process.stderr = stderr;
process.kill = () => {
onKill();
process.emit('close', 0);
return true;
};
return process;
}
test('MecabTokenizer reuses a persistent parser process across subtitle lines', async () => {
let spawnCalls = 0;
let killCalls = 0;
let timerId = 0;
const timers = new Map<number, () => void>();
const tokenizer = new MecabTokenizer({
execSyncFn: (() => '/usr/bin/mecab') as unknown as typeof childProcess.execSync,
spawnFn: (() => {
spawnCalls += 1;
return createFakeMecabProcess(() => {
killCalls += 1;
});
}) as unknown as typeof childProcess.spawn,
setTimeoutFn: (callback) => {
timerId += 1;
timers.set(timerId, callback);
return timerId as unknown as ReturnType<typeof setTimeout>;
},
clearTimeoutFn: (timeout) => {
timers.delete(timeout as unknown as number);
},
idleShutdownMs: 60_000,
});
assert.equal(await tokenizer.checkAvailability(), true);
const first = await tokenizer.tokenize('猫');
const second = await tokenizer.tokenize('犬');
assert.equal(first?.[0]?.word, '猫');
assert.equal(second?.[0]?.word, '犬');
assert.equal(spawnCalls, 1);
assert.equal(killCalls, 0);
});
test('MecabTokenizer shuts down after idle timeout and restarts on new activity', async () => {
let spawnCalls = 0;
let killCalls = 0;
let timerId = 0;
const timers = new Map<number, () => void>();
const tokenizer = new MecabTokenizer({
execSyncFn: (() => '/usr/bin/mecab') as unknown as typeof childProcess.execSync,
spawnFn: (() => {
spawnCalls += 1;
return createFakeMecabProcess(() => {
killCalls += 1;
});
}) as unknown as typeof childProcess.spawn,
setTimeoutFn: (callback) => {
timerId += 1;
timers.set(timerId, callback);
return timerId as unknown as ReturnType<typeof setTimeout>;
},
clearTimeoutFn: (timeout) => {
timers.delete(timeout as unknown as number);
},
idleShutdownMs: 5_000,
});
assert.equal(await tokenizer.checkAvailability(), true);
await tokenizer.tokenize('猫');
assert.equal(spawnCalls, 1);
const pendingTimer = [...timers.values()][0];
assert.ok(pendingTimer, 'expected idle shutdown timer');
pendingTimer?.();
assert.equal(killCalls, 1);
await tokenizer.tokenize('犬');
assert.equal(spawnCalls, 2);
});

View File

@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { spawn, execSync } from 'child_process'; import * as childProcess from 'child_process';
import { PartOfSpeech, Token, MecabStatus } from './types'; import { PartOfSpeech, Token, MecabStatus } from './types';
import { createLogger } from './logger'; import { createLogger } from './logger';
@@ -89,18 +89,59 @@ export function parseMecabLine(line: string): Token | null {
export interface MecabTokenizerOptions { export interface MecabTokenizerOptions {
mecabCommand?: string; mecabCommand?: string;
dictionaryPath?: string; dictionaryPath?: string;
idleShutdownMs?: number;
spawnFn?: typeof childProcess.spawn;
execSyncFn?: typeof childProcess.execSync;
setTimeoutFn?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearTimeoutFn?: (timer: ReturnType<typeof setTimeout>) => void;
}
interface MecabQueuedRequest {
text: string;
retryCount: number;
resolve: (tokens: Token[] | null) => void;
}
interface MecabActiveRequest extends MecabQueuedRequest {
lines: string[];
stderr: string;
} }
export class MecabTokenizer { export class MecabTokenizer {
private static readonly DEFAULT_IDLE_SHUTDOWN_MS = 30_000;
private static readonly MAX_RETRY_COUNT = 1;
private mecabPath: string | null = null; private mecabPath: string | null = null;
private mecabCommand: string; private mecabCommand: string;
private dictionaryPath: string | null; private dictionaryPath: string | null;
private available: boolean = false; private available: boolean = false;
private enabled: boolean = true; private enabled: boolean = true;
private idleShutdownMs: number;
private readonly spawnFn: typeof childProcess.spawn;
private readonly execSyncFn: typeof childProcess.execSync;
private readonly setTimeoutFn: (
callback: () => void,
delayMs: number,
) => ReturnType<typeof setTimeout>;
private readonly clearTimeoutFn: (timer: ReturnType<typeof setTimeout>) => void;
private mecabProcess: ReturnType<typeof childProcess.spawn> | null = null;
private idleShutdownTimer: ReturnType<typeof setTimeout> | null = null;
private stdoutBuffer = '';
private requestQueue: MecabQueuedRequest[] = [];
private activeRequest: MecabActiveRequest | null = null;
constructor(options: MecabTokenizerOptions = {}) { constructor(options: MecabTokenizerOptions = {}) {
this.mecabCommand = options.mecabCommand?.trim() || 'mecab'; this.mecabCommand = options.mecabCommand?.trim() || 'mecab';
this.dictionaryPath = options.dictionaryPath?.trim() || null; this.dictionaryPath = options.dictionaryPath?.trim() || null;
this.idleShutdownMs = Math.max(
0,
Math.floor(options.idleShutdownMs ?? MecabTokenizer.DEFAULT_IDLE_SHUTDOWN_MS),
);
this.spawnFn = options.spawnFn ?? childProcess.spawn;
this.execSyncFn = options.execSyncFn ?? childProcess.execSync;
this.setTimeoutFn =
options.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
this.clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
} }
async checkAvailability(): Promise<boolean> { async checkAvailability(): Promise<boolean> {
@@ -108,9 +149,10 @@ export class MecabTokenizer {
const command = this.mecabCommand; const command = this.mecabCommand;
const result = command.includes('/') const result = command.includes('/')
? command ? command
: execSync(`which ${command}`, { encoding: 'utf-8' }).trim(); : this.execSyncFn(`which ${command}`, { encoding: 'utf-8' });
if (result) { const resolvedPath = String(result).trim();
this.mecabPath = result; if (resolvedPath) {
this.mecabPath = resolvedPath;
this.available = true; this.available = true;
log.info('MeCab found at:', this.mecabPath); log.info('MeCab found at:', this.mecabPath);
return true; return true;
@@ -119,81 +161,257 @@ export class MecabTokenizer {
log.info('MeCab not found on system'); log.info('MeCab not found on system');
} }
this.stopPersistentProcess();
this.available = false; this.available = false;
return false; return false;
} }
async tokenize(text: string): Promise<Token[] | null> { async tokenize(text: string): Promise<Token[] | null> {
if (!this.available || !this.enabled || !text) { const normalizedText = text.replace(/\r?\n/g, ' ').trim();
if (!this.available || !this.enabled || !normalizedText) {
return null; return null;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const mecabArgs: string[] = []; this.clearIdleShutdownTimer();
if (this.dictionaryPath) { this.requestQueue.push({
mecabArgs.push('-d', this.dictionaryPath); text: normalizedText,
} retryCount: 0,
const mecab = spawn(this.mecabPath ?? this.mecabCommand, mecabArgs, { resolve,
});
this.processQueue();
});
}
private processQueue(): void {
if (this.activeRequest) {
return;
}
const request = this.requestQueue.shift();
if (!request) {
this.scheduleIdleShutdown();
return;
}
if (!this.ensurePersistentProcess()) {
this.retryOrResolveRequest(request);
this.processQueue();
return;
}
this.activeRequest = {
...request,
lines: [],
stderr: '',
};
try {
this.mecabProcess?.stdin?.write(`${request.text}\n`);
} catch (error) {
log.error('Failed to write to MeCab process:', (error as Error).message);
this.retryOrResolveRequest(request);
this.activeRequest = null;
this.stopPersistentProcess();
this.processQueue();
}
}
private retryOrResolveRequest(request: MecabQueuedRequest): void {
if (request.retryCount < MecabTokenizer.MAX_RETRY_COUNT && this.enabled && this.available) {
this.requestQueue.push({
...request,
retryCount: request.retryCount + 1,
});
return;
}
request.resolve(null);
}
private ensurePersistentProcess(): boolean {
if (this.mecabProcess) {
return true;
}
const mecabArgs: string[] = [];
if (this.dictionaryPath) {
mecabArgs.push('-d', this.dictionaryPath);
}
let mecab: ReturnType<typeof childProcess.spawn>;
try {
mecab = this.spawnFn(this.mecabPath ?? this.mecabCommand, mecabArgs, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
} catch (error) {
log.error('Failed to spawn MeCab:', (error as Error).message);
return false;
}
let stdout = ''; if (!mecab.stdin || !mecab.stdout || !mecab.stderr) {
let stderr = ''; log.error('Failed to spawn MeCab: missing stdio pipes');
try {
mecab.kill();
} catch {}
return false;
}
mecab.stdout.on('data', (data: Buffer) => { this.stdoutBuffer = '';
stdout += data.toString(); mecab.stdout.on('data', (data: Buffer | string) => {
}); this.handleStdoutChunk(data.toString());
mecab.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
mecab.on('close', (code: number | null) => {
if (code !== 0) {
log.error('MeCab process exited with code:', code);
if (stderr) {
log.error('MeCab stderr:', stderr);
}
resolve(null);
return;
}
const lines = stdout.split('\n');
const tokens: Token[] = [];
for (const line of lines) {
const token = parseMecabLine(line);
if (token) {
tokens.push(token);
}
}
if (tokens.length === 0 && text.trim().length > 0) {
const trimmedStdout = stdout.trim();
const trimmedStderr = stderr.trim();
if (trimmedStdout) {
log.warn(
'MeCab returned no parseable tokens.',
`command=${this.mecabPath ?? this.mecabCommand}`,
`stdout=${trimmedStdout.slice(0, 1024)}`,
);
}
if (trimmedStderr) {
log.warn('MeCab stderr while tokenizing:', trimmedStderr);
}
}
resolve(tokens);
});
mecab.on('error', (err: Error) => {
log.error('Failed to spawn MeCab:', err.message);
resolve(null);
});
mecab.stdin.write(text);
mecab.stdin.end();
}); });
mecab.stderr.on('data', (data: Buffer | string) => {
if (!this.activeRequest) {
return;
}
this.activeRequest.stderr += data.toString();
});
mecab.on('error', (error: Error) => {
this.handlePersistentProcessEnded(mecab, `spawn error: ${error.message}`);
});
mecab.on('close', (code: number | null) => {
this.handlePersistentProcessEnded(mecab, `exit code ${String(code)}`);
});
this.mecabProcess = mecab;
return true;
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
while (true) {
const newlineIndex = this.stdoutBuffer.indexOf('\n');
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, '');
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
this.handleStdoutLine(line);
}
}
private handleStdoutLine(line: string): void {
if (!this.activeRequest) {
return;
}
if (line === 'EOS') {
this.resolveActiveRequest();
return;
}
if (!line.trim()) {
return;
}
this.activeRequest.lines.push(line);
}
private resolveActiveRequest(): void {
const current = this.activeRequest;
if (!current) {
return;
}
this.activeRequest = null;
const tokens: Token[] = [];
for (const line of current.lines) {
const token = parseMecabLine(line);
if (token) {
tokens.push(token);
}
}
if (tokens.length === 0 && current.text.trim().length > 0) {
const trimmedStdout = current.lines.join('\n').trim();
const trimmedStderr = current.stderr.trim();
if (trimmedStdout) {
log.warn(
'MeCab returned no parseable tokens.',
`command=${this.mecabPath ?? this.mecabCommand}`,
`stdout=${trimmedStdout.slice(0, 1024)}`,
);
}
if (trimmedStderr) {
log.warn('MeCab stderr while tokenizing:', trimmedStderr);
}
}
current.resolve(tokens);
this.processQueue();
}
private handlePersistentProcessEnded(
process: ReturnType<typeof childProcess.spawn>,
reason: string,
): void {
if (this.mecabProcess !== process) {
return;
}
this.mecabProcess = null;
this.stdoutBuffer = '';
this.clearIdleShutdownTimer();
const pending: MecabQueuedRequest[] = [];
if (this.activeRequest) {
pending.push({
text: this.activeRequest.text,
retryCount: this.activeRequest.retryCount,
resolve: this.activeRequest.resolve,
});
}
this.activeRequest = null;
if (this.requestQueue.length > 0) {
pending.push(...this.requestQueue);
}
this.requestQueue = [];
if (pending.length > 0) {
log.warn(
`MeCab parser process ended during active work (${reason}); retrying pending request(s).`,
);
for (const request of pending) {
this.retryOrResolveRequest(request);
}
this.processQueue();
}
}
private scheduleIdleShutdown(): void {
this.clearIdleShutdownTimer();
if (this.idleShutdownMs <= 0 || !this.mecabProcess) {
return;
}
this.idleShutdownTimer = this.setTimeoutFn(() => {
this.idleShutdownTimer = null;
if (this.activeRequest || this.requestQueue.length > 0) {
return;
}
this.stopPersistentProcess();
}, this.idleShutdownMs);
const timerWithUnref = this.idleShutdownTimer as { unref?: () => void };
if (typeof timerWithUnref.unref === 'function') {
timerWithUnref.unref();
}
}
private clearIdleShutdownTimer(): void {
if (!this.idleShutdownTimer) {
return;
}
this.clearTimeoutFn(this.idleShutdownTimer);
this.idleShutdownTimer = null;
}
private stopPersistentProcess(): void {
const process = this.mecabProcess;
if (!process) {
return;
}
this.mecabProcess = null;
this.stdoutBuffer = '';
this.clearIdleShutdownTimer();
try {
process.kill();
} catch {}
} }
getStatus(): MecabStatus { getStatus(): MecabStatus {
@@ -206,6 +424,25 @@ export class MecabTokenizer {
setEnabled(enabled: boolean): void { setEnabled(enabled: boolean): void {
this.enabled = enabled; this.enabled = enabled;
if (!enabled) {
const pending: MecabQueuedRequest[] = [];
if (this.activeRequest) {
pending.push({
text: this.activeRequest.text,
retryCount: MecabTokenizer.MAX_RETRY_COUNT,
resolve: this.activeRequest.resolve,
});
}
if (this.requestQueue.length > 0) {
pending.push(...this.requestQueue);
}
this.activeRequest = null;
this.requestQueue = [];
for (const request of pending) {
request.resolve(null);
}
this.stopPersistentProcess();
}
} }
} }

View File

@@ -79,7 +79,7 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2'); assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
}); });
test('computeWordClass keeps known/N+1 color classes exclusive over frequency classes', () => { test('computeWordClass keeps known and N+1 color classes exclusive over frequency classes', () => {
const known = createToken({ const known = createToken({
isKnown: true, isKnown: true,
frequencyRank: 10, frequencyRank: 10,
@@ -228,10 +228,12 @@ test('getFrequencyRankLabelForToken returns rank only for frequency-colored toke
}; };
const frequencyToken = createToken({ surface: '頻度', frequencyRank: 20 }); const frequencyToken = createToken({ surface: '頻度', frequencyRank: 20 });
const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 }); const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 });
const nPlusOneToken = createToken({ surface: '目標', isNPlusOneTarget: true, frequencyRank: 20 });
const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 }); const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 });
assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20'); assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20'); assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(nPlusOneToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null); assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null);
}); });

View File

@@ -189,10 +189,6 @@ export function getFrequencyRankLabelForToken(
token: MergedToken, token: MergedToken,
frequencySettings?: Partial<FrequencyRenderSettings>, frequencySettings?: Partial<FrequencyRenderSettings>,
): string | null { ): string | null {
if (token.isNPlusOneTarget) {
return null;
}
const resolvedFrequencySettings = { const resolvedFrequencySettings = {
...DEFAULT_FREQUENCY_RENDER_SETTINGS, ...DEFAULT_FREQUENCY_RENDER_SETTINGS,
...frequencySettings, ...frequencySettings,

View File

@@ -168,6 +168,7 @@ export function mergeTokens(
tokens: Token[], tokens: Token[],
isKnownWord: (text: string) => boolean = () => false, isKnownWord: (text: string) => boolean = () => false,
knownWordMatchMode: 'headword' | 'surface' = 'headword', knownWordMatchMode: 'headword' | 'surface' = 'headword',
shouldLookupKnownWords = true,
): MergedToken[] { ): MergedToken[] {
if (!tokens || tokens.length === 0) { if (!tokens || tokens.length === 0) {
return []; return [];
@@ -176,6 +177,12 @@ export function mergeTokens(
const result: MergedToken[] = []; const result: MergedToken[] = [];
let charOffset = 0; let charOffset = 0;
let lastStandaloneToken: Token | null = null; let lastStandaloneToken: Token | null = null;
const resolveKnownMatch = (text: string | undefined): boolean => {
if (!shouldLookupKnownWords || !text) {
return false;
}
return isKnownWord(text);
};
for (const token of tokens) { for (const token of tokens) {
const start = charOffset; const start = charOffset;
@@ -189,7 +196,6 @@ export function mergeTokens(
} }
const tokenReading = ignoreReading(token) ? '' : token.katakanaReading || token.word; const tokenReading = ignoreReading(token) ? '' : token.katakanaReading || token.word;
if (shouldMergeToken && result.length > 0) { if (shouldMergeToken && result.length > 0) {
const prev = result.pop()!; const prev = result.pop()!;
const mergedHeadword = prev.headword; const mergedHeadword = prev.headword;
@@ -210,7 +216,7 @@ export function mergeTokens(
pos2: prev.pos2 ?? token.pos2, pos2: prev.pos2 ?? token.pos2,
pos3: prev.pos3 ?? token.pos3, pos3: prev.pos3 ?? token.pos3,
isMerged: true, isMerged: true,
isKnown: headwordForKnownMatch ? isKnownWord(headwordForKnownMatch) : false, isKnown: resolveKnownMatch(headwordForKnownMatch),
isNPlusOneTarget: false, isNPlusOneTarget: false,
}); });
} else { } else {
@@ -231,7 +237,7 @@ export function mergeTokens(
pos2: token.pos2, pos2: token.pos2,
pos3: token.pos3, pos3: token.pos3,
isMerged: false, isMerged: false,
isKnown: headwordForKnownMatch ? isKnownWord(headwordForKnownMatch) : false, isKnown: resolveKnownMatch(headwordForKnownMatch),
isNPlusOneTarget: false, isNPlusOneTarget: false,
}); });
} }

View File

@@ -124,6 +124,7 @@ export interface NotificationOptions {
export interface MpvClient { export interface MpvClient {
currentSubText: string; currentSubText: string;
currentVideoPath: string; currentVideoPath: string;
currentMediaTitle?: string | null;
currentTimePos: number; currentTimePos: number;
currentSubStart: number; currentSubStart: number;
currentSubEnd: number; currentSubEnd: number;