mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
be4db24861
|
|||
|
83d21c4b6d
|
|||
|
e744fab067
|
|||
|
5167e3a494
|
|||
|
aff4e91bbb
|
|||
|
737101fe9e
|
|||
|
629fe97ef7
|
|||
|
fa97472bce
|
|||
|
83f13df627
|
|||
|
cde231b1ff
|
|||
|
7161fc3513
|
|||
|
9a91951656
|
|||
|
11e9c721c6
|
|||
|
3c66ea6b30
|
|||
|
79f37f3986
|
|||
|
f1b85b0751
|
|||
|
1ab5d00de0
|
|||
|
17a417e639
|
|||
|
68e5a7fef3
|
@@ -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.
|
||||
|
||||
Scope:
|
||||
|
||||
- New config key: `subtitleStyle.autoPauseVideoOnHover`.
|
||||
- Default should be enabled.
|
||||
- Hover pause/resume must not unpause if playback was already paused before hover.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Scope:
|
||||
|
||||
- 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`.
|
||||
- Main process signals readiness via mpv script message after tokenized subtitle delivery.
|
||||
@@ -43,6 +44,7 @@ Scope:
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
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 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.
|
||||
|
||||
@@ -18,10 +18,12 @@ ordinal: 10000
|
||||
Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- Subtitle file downloads and loads into mpv.
|
||||
- Jimaku modal remains open until manual close.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- On successful `jimakuDownloadFile` result, close modal immediately.
|
||||
- Keep error behavior unchanged (stay open + show error).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Example:
|
||||
|
||||
- Current media: `anime.mkv`
|
||||
- Downloaded subtitle extension: `.srt`
|
||||
- Saved subtitle path: `anime.ja.srt`
|
||||
|
||||
Scope:
|
||||
|
||||
- Apply in Jimaku download IPC path before writing file.
|
||||
- Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists).
|
||||
- Keep mpv load flow unchanged except using renamed path.
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
3
bun.lock
3
bun.lock
@@ -6,6 +6,7 @@
|
||||
"name": "subminer",
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"axios": "^1.13.5",
|
||||
"commander": "^14.0.3",
|
||||
"discord-rpc": "^4.0.1",
|
||||
@@ -188,6 +189,8 @@
|
||||
|
||||
"@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-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
|
||||
@@ -5,8 +5,25 @@ import '@catppuccin/vitepress/theme/macchiato/mauve.css';
|
||||
import './mermaid-modal.css';
|
||||
|
||||
let mermaidLoader: Promise<any> | null = null;
|
||||
let plausibleTrackerInitialized = false;
|
||||
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() {
|
||||
if (typeof document === 'undefined') {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"`) |
|
||||
|
||||
### 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)"`) |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||
| `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) |
|
||||
| `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) |
|
||||
@@ -322,6 +322,7 @@ Set the initial vertical subtitle position (measured from the bottom of the scre
|
||||
| Option | Values | Description |
|
||||
| ---------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| `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.
|
||||
|
||||
### Secondary Subtitles
|
||||
@@ -364,21 +365,23 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
|
||||
**Default keybindings:**
|
||||
|
||||
| Key | Command | Description |
|
||||
| ----------------- | ---------------------------- | ------------------------------------- |
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||
| Key | Command | Description |
|
||||
| -------------------- | ---------------------------- | ------------------------------------- |
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
||||
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
|
||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||
|
||||
**Custom keybindings example:**
|
||||
|
||||
@@ -402,11 +405,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "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.)
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -79,18 +79,18 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| ----------------------- | --------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||
| Flag | Description |
|
||||
| --------------------- | --------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||
|
||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
||||
|
||||
|
||||
@@ -120,27 +120,27 @@ aniskip_button_duration=3
|
||||
|
||||
### Option Reference
|
||||
|
||||
| Option | Default | Values | Description |
|
||||
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||
| `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_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 |
|
||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||
| `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_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 |
|
||||
| Option | Default | Values | Description |
|
||||
| ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||
| `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_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 |
|
||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||
| `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_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 |
|
||||
|
||||
## Binary Auto-Detection
|
||||
|
||||
|
||||
15
docs/plausible.test.ts
Normal file
15
docs/plausible.test.ts
Normal 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');
|
||||
});
|
||||
@@ -46,6 +46,8 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `ArrowDown` | Seek backward 60 seconds |
|
||||
| `Shift+H` | Jump to previous subtitle |
|
||||
| `Shift+L` | Jump to next subtitle |
|
||||
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Shift+]` | Shift subtitle delay to next subtitle cue |
|
||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||
| `Q` | Quit mpv |
|
||||
|
||||
@@ -143,11 +143,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||
|
||||
if (shouldPauseUntilOverlayReady) {
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'Configured to pause mpv until overlay and tokenization are ready',
|
||||
);
|
||||
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||
}
|
||||
|
||||
startMpv(
|
||||
@@ -198,11 +194,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
if (ready) {
|
||||
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
} else {
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
|
||||
);
|
||||
log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
|
||||
}
|
||||
} else if (ready) {
|
||||
log(
|
||||
|
||||
@@ -52,7 +52,10 @@ export function parsePluginRuntimeConfigContent(
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_visible_overlay') {
|
||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value);
|
||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
||||
'auto_start_visible_overlay',
|
||||
value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_pause_until_ready') {
|
||||
|
||||
@@ -239,8 +239,7 @@ export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAu
|
||||
const serverUrl = sanitizeServerUrl(
|
||||
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
|
||||
);
|
||||
const accessToken =
|
||||
typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
|
||||
const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
|
||||
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
|
||||
if (!serverUrl || !accessToken) return null;
|
||||
|
||||
@@ -271,9 +270,7 @@ export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number):
|
||||
const buffer = fs.readFileSync(logPath);
|
||||
if (buffer.length === 0) return '';
|
||||
const normalizedOffset =
|
||||
Number.isFinite(offsetBytes) && offsetBytes >= 0
|
||||
? Math.floor(offsetBytes)
|
||||
: 0;
|
||||
Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
|
||||
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
|
||||
return buffer.subarray(startOffset).toString('utf8');
|
||||
} catch {
|
||||
@@ -399,7 +396,9 @@ async function runAppJellyfinCommand(
|
||||
|
||||
const hasCommandSignal = (output: string): boolean => {
|
||||
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') {
|
||||
return (
|
||||
@@ -550,7 +549,9 @@ async function resolveJellyfinSelectionViaApp(
|
||||
}
|
||||
|
||||
const configuredDefaultLibraryId = session.defaultLibraryId;
|
||||
const hasConfiguredDefault = libraries.some((library) => library.id === configuredDefaultLibraryId);
|
||||
const hasConfiguredDefault = libraries.some(
|
||||
(library) => library.id === configuredDefaultLibraryId,
|
||||
);
|
||||
let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : '';
|
||||
if (!libraryId) {
|
||||
libraryId = pickLibrary(
|
||||
|
||||
@@ -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."}
|
||||
`);
|
||||
|
||||
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', () => {
|
||||
@@ -385,7 +388,9 @@ test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle er
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance('Missing Jellyfin session. Run --jellyfin-login first.'),
|
||||
shouldRetryWithStartForNoRunningInstance(
|
||||
'Missing Jellyfin session. Run --jellyfin-login first.',
|
||||
),
|
||||
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', () => {
|
||||
assert.deepEqual(parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), {
|
||||
seriesName: 'KONOSUBA',
|
||||
seasonNumber: 1,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
|
||||
{
|
||||
seriesName: 'KONOSUBA',
|
||||
seasonNumber: 1,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
|
||||
seriesName: 'Frieren',
|
||||
seasonNumber: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -58,6 +58,7 @@
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"axios": "^1.13.5",
|
||||
"commander": "^14.0.3",
|
||||
"discord-rpc": "^4.0.1",
|
||||
|
||||
@@ -3,6 +3,8 @@ local M = {}
|
||||
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||
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)
|
||||
local mp = ctx.mp
|
||||
@@ -70,28 +72,50 @@ function M.create(ctx)
|
||||
state.auto_play_ready_timeout = nil
|
||||
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_osd_timer()
|
||||
state.auto_play_ready_gate_armed = false
|
||||
if was_armed and should_resume then
|
||||
mp.set_property_native("pause", false)
|
||||
end
|
||||
end
|
||||
|
||||
local function release_auto_play_ready_gate(reason)
|
||||
if not state.auto_play_ready_gate_armed then
|
||||
return
|
||||
end
|
||||
disarm_auto_play_ready_gate()
|
||||
disarm_auto_play_ready_gate({ resume_playback = 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"))
|
||||
end
|
||||
|
||||
local function arm_auto_play_ready_gate()
|
||||
if state.auto_play_ready_gate_armed then
|
||||
clear_auto_play_ready_timeout()
|
||||
clear_auto_play_ready_osd_timer()
|
||||
end
|
||||
state.auto_play_ready_gate_armed = 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")
|
||||
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
|
||||
if not state.auto_play_ready_gate_armed then
|
||||
@@ -251,6 +275,23 @@ function M.create(ctx)
|
||||
if state.overlay_running then
|
||||
if overrides.auto_start_trigger == true then
|
||||
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
|
||||
end
|
||||
subminer_log("info", "process", "Overlay already running")
|
||||
@@ -287,7 +328,7 @@ function M.create(ctx)
|
||||
)
|
||||
end
|
||||
|
||||
if attempt == 1 then
|
||||
if attempt == 1 and not state.auto_play_ready_gate_armed then
|
||||
show_osd("Starting...")
|
||||
end
|
||||
state.overlay_running = true
|
||||
|
||||
@@ -29,6 +29,7 @@ function M.new()
|
||||
},
|
||||
auto_play_ready_gate_armed = false,
|
||||
auto_play_ready_timeout = nil,
|
||||
auto_play_ready_osd_timer = nil,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ local function run_plugin_scenario(config)
|
||||
osd = {},
|
||||
logs = {},
|
||||
property_sets = {},
|
||||
periodic_timers = {},
|
||||
}
|
||||
|
||||
local function make_mp_stub()
|
||||
@@ -90,10 +91,32 @@ local function run_plugin_scenario(config)
|
||||
end
|
||||
end
|
||||
|
||||
function mp.add_timeout(_seconds, callback)
|
||||
if callback then
|
||||
function mp.add_timeout(seconds, callback)
|
||||
local timeout = {
|
||||
killed = false,
|
||||
}
|
||||
function timeout:kill()
|
||||
self.killed = true
|
||||
end
|
||||
|
||||
local delay = tonumber(seconds) or 0
|
||||
if callback and delay < 5 then
|
||||
callback()
|
||||
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
|
||||
|
||||
function mp.register_script_message(name, fn)
|
||||
@@ -281,6 +304,26 @@ local function find_control_call(async_calls, flag)
|
||||
return nil
|
||||
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 args = (call and call.args) or {}
|
||||
for _, value in ipairs(args) do
|
||||
@@ -352,6 +395,16 @@ local function count_osd_message(messages, target)
|
||||
return count
|
||||
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 listeners = recorded.events[name] or {}
|
||||
for _, listener in ipairs(listeners) do
|
||||
@@ -493,12 +546,64 @@ do
|
||||
count_start_calls(recorded.async_calls) == 1,
|
||||
"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(
|
||||
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
|
||||
"duplicate auto-start events should not show Already running OSD"
|
||||
)
|
||||
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
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -528,13 +633,54 @@ do
|
||||
"autoplay-ready script message should resume mpv playback"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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
|
||||
|
||||
do
|
||||
|
||||
@@ -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.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);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,55 @@ interface NoteInfo {
|
||||
|
||||
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 {
|
||||
private client: AnkiConnectClient;
|
||||
private mediaGenerator: MediaGenerator;
|
||||
@@ -729,8 +778,12 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
||||
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : '';
|
||||
const filenameWithExt = videoFilename || fallbackFilename;
|
||||
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
|
||||
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
|
||||
const filenameWithExt =
|
||||
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
|
||||
? mediaTitle || videoFilename
|
||||
: videoFilename || mediaTitle) || fallbackFilename;
|
||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||
|
||||
const currentTimePos =
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import test from 'node:test';
|
||||
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', () => {
|
||||
const args = parseArgs([
|
||||
@@ -148,10 +153,7 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
'/tmp/subminer-jf-response.json',
|
||||
]);
|
||||
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
|
||||
assert.equal(
|
||||
jellyfinPreviewAuth.jellyfinResponsePath,
|
||||
'/tmp/subminer-jf-response.json',
|
||||
);
|
||||
assert.equal(jellyfinPreviewAuth.jellyfinResponsePath, '/tmp/subminer-jf-response.json');
|
||||
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
|
||||
assert.equal(shouldStartApp(jellyfinPreviewAuth), false);
|
||||
|
||||
|
||||
@@ -240,7 +240,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true;
|
||||
if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false;
|
||||
} 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') {
|
||||
args.jellyfinRecursive = false;
|
||||
} else if (value === 'true' || value === '1' || value === 'yes') {
|
||||
|
||||
@@ -47,7 +47,10 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||
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.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.immersionTracking.enabled, true);
|
||||
assert.equal(config.immersionTracking.dbPath, '');
|
||||
|
||||
@@ -44,6 +44,8 @@ export const SPECIAL_COMMANDS = {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-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;
|
||||
|
||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
@@ -56,6 +58,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
{ key: 'ArrowDown', command: ['seek', -60] },
|
||||
{ key: 'Shift+KeyH', 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+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||
{ key: 'KeyQ', command: ['quit'] },
|
||||
|
||||
@@ -99,8 +99,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (isObject(src.subtitleStyle)) {
|
||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover =
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
@@ -161,8 +160,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (autoPauseVideoOnHover !== undefined) {
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover;
|
||||
} else if (
|
||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !==
|
||||
undefined
|
||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !== undefined
|
||||
) {
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
|
||||
warn(
|
||||
|
||||
@@ -129,3 +129,39 @@ test('createFrequencyDictionaryLookup parses composite displayValue by primary r
|
||||
assert.equal(lookup('鍛える'), 3272);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface FrequencyDictionaryLookupOptions {
|
||||
@@ -13,6 +13,17 @@ interface FrequencyDictionaryEntry {
|
||||
|
||||
const FREQUENCY_BANK_FILE_GLOB = /^term_meta_bank_.*\.json$/;
|
||||
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 {
|
||||
return value.trim().toLowerCase();
|
||||
@@ -93,16 +104,22 @@ function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry |
|
||||
};
|
||||
}
|
||||
|
||||
function addEntriesToMap(
|
||||
async function addEntriesToMap(
|
||||
rawEntries: unknown,
|
||||
terms: Map<string, number>,
|
||||
): { duplicateCount: number } {
|
||||
): Promise<{ duplicateCount: number }> {
|
||||
if (!Array.isArray(rawEntries)) {
|
||||
return { duplicateCount: 0 };
|
||||
}
|
||||
|
||||
let duplicateCount = 0;
|
||||
let processedCount = 0;
|
||||
for (const rawEntry of rawEntries) {
|
||||
processedCount += 1;
|
||||
if (processedCount % ENTRY_YIELD_INTERVAL === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
const entry = asFrequencyDictionaryEntry(rawEntry);
|
||||
if (!entry) {
|
||||
continue;
|
||||
@@ -119,15 +136,15 @@ function addEntriesToMap(
|
||||
return { duplicateCount };
|
||||
}
|
||||
|
||||
function collectDictionaryFromPath(
|
||||
async function collectDictionaryFromPath(
|
||||
dictionaryPath: string,
|
||||
log: (message: string) => void,
|
||||
): Map<string, number> {
|
||||
): Promise<Map<string, number>> {
|
||||
const terms = new Map<string, number>();
|
||||
|
||||
let fileNames: string[];
|
||||
try {
|
||||
fileNames = fs.readdirSync(dictionaryPath);
|
||||
fileNames = await fs.readdir(dictionaryPath);
|
||||
} catch (error) {
|
||||
log(`Failed to read frequency dictionary directory ${dictionaryPath}: ${String(error)}`);
|
||||
return terms;
|
||||
@@ -143,7 +160,7 @@ function collectDictionaryFromPath(
|
||||
const bankPath = path.join(dictionaryPath, bankFile);
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fs.readFileSync(bankPath, 'utf-8');
|
||||
rawText = await fs.readFile(bankPath, 'utf-8');
|
||||
} catch {
|
||||
log(`Failed to read frequency dictionary file ${bankPath}`);
|
||||
continue;
|
||||
@@ -151,6 +168,7 @@ function collectDictionaryFromPath(
|
||||
|
||||
let rawEntries: unknown;
|
||||
try {
|
||||
await yieldToEventLoop();
|
||||
rawEntries = JSON.parse(rawText) as unknown;
|
||||
} catch {
|
||||
log(`Failed to parse frequency dictionary file as JSON: ${bankPath}`);
|
||||
@@ -158,7 +176,7 @@ function collectDictionaryFromPath(
|
||||
}
|
||||
|
||||
const beforeSize = terms.size;
|
||||
const { duplicateCount } = addEntriesToMap(rawEntries, terms);
|
||||
const { duplicateCount } = await addEntriesToMap(rawEntries, terms);
|
||||
if (duplicateCount > 0) {
|
||||
log(
|
||||
`Frequency dictionary ignored ${duplicateCount} duplicate term entr${
|
||||
@@ -185,11 +203,11 @@ export async function createFrequencyDictionaryLookup(
|
||||
let isDirectory = false;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dictionaryPath)) {
|
||||
isDirectory = (await fs.stat(dictionaryPath)).isDirectory();
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, 'ENOENT')) {
|
||||
continue;
|
||||
}
|
||||
isDirectory = fs.statSync(dictionaryPath).isDirectory();
|
||||
} catch (error) {
|
||||
options.log(
|
||||
`Failed to inspect frequency dictionary path ${dictionaryPath}: ${String(error)}`,
|
||||
);
|
||||
@@ -201,7 +219,7 @@ export async function createFrequencyDictionaryLookup(
|
||||
}
|
||||
|
||||
foundDictionaryPathCount += 1;
|
||||
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
|
||||
const terms = await collectDictionaryFromPath(dictionaryPath, options.log);
|
||||
if (terms.size > 0) {
|
||||
options.log(`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`);
|
||||
return (term: string): number | null => {
|
||||
|
||||
@@ -46,23 +46,31 @@ export function pruneRetention(
|
||||
const dayCutoff = nowMs - policy.dailyRollupRetentionMs;
|
||||
const monthCutoff = nowMs - policy.monthlyRollupRetentionMs;
|
||||
|
||||
const deletedSessionEvents = (db
|
||||
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||
.run(eventCutoff) as { changes: number }).changes;
|
||||
const deletedTelemetryRows = (db
|
||||
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||
.run(telemetryCutoff) as { changes: number }).changes;
|
||||
const deletedDailyRows = (db
|
||||
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
|
||||
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }).changes;
|
||||
const deletedMonthlyRows = (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;
|
||||
const deletedSessionEvents = (
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes;
|
||||
const deletedTelemetryRows = (
|
||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes;
|
||||
const deletedDailyRows = (
|
||||
db
|
||||
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
|
||||
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }
|
||||
).changes;
|
||||
const deletedMonthlyRows = (
|
||||
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 {
|
||||
deletedSessionEvents,
|
||||
|
||||
@@ -17,6 +17,9 @@ test('extractLineVocabulary returns words and unique kanji', () => {
|
||||
new Set(result.words.map((entry) => `${entry.headword}/${entry.word}`)),
|
||||
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(['你', '好', '猫']));
|
||||
});
|
||||
|
||||
@@ -97,7 +97,8 @@ export function extractLineVocabulary(value: string): ExtractedLineVocabulary {
|
||||
if (!cleaned) return { words: [], kanji: [] };
|
||||
|
||||
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) ?? [];
|
||||
for (const rawWord of rawWords) {
|
||||
const normalizedWord = normalizeText(rawWord.toLowerCase());
|
||||
|
||||
@@ -19,15 +19,8 @@ export function startSessionRecord(
|
||||
CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
sessionUuid,
|
||||
videoId,
|
||||
startedAtMs,
|
||||
SESSION_STATUS_ACTIVE,
|
||||
startedAtMs,
|
||||
nowMs,
|
||||
);
|
||||
)
|
||||
.run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, nowMs);
|
||||
const sessionId = Number(result.lastInsertRowid);
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -59,9 +59,7 @@ testIfSqlite('ensureSchema creates immersion core tables', () => {
|
||||
assert.ok(tableNames.has('imm_rollup_state'));
|
||||
|
||||
const rollupStateRow = db
|
||||
.prepare(
|
||||
'SELECT state_value FROM imm_rollup_state WHERE state_key = ?',
|
||||
)
|
||||
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
||||
.get('last_rollup_sample_ms') as {
|
||||
state_value: number;
|
||||
} | null;
|
||||
@@ -188,7 +186,9 @@ testIfSqlite('executeQueuedWrite inserts and upserts word and kanji rows', () =>
|
||||
stmts.kanjiUpsertStmt.run('日', 8.0, 11.0);
|
||||
|
||||
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 {
|
||||
headword: string;
|
||||
frequency: number;
|
||||
|
||||
@@ -426,11 +426,7 @@ export function getOrCreateVideoRecord(
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(
|
||||
details.canonicalTitle || 'unknown',
|
||||
Date.now(),
|
||||
existing.video_id,
|
||||
);
|
||||
).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
|
||||
return existing.video_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,11 @@ interface QueuedKanjiWrite {
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export type QueuedWrite = QueuedTelemetryWrite | QueuedEventWrite | QueuedWordWrite | QueuedKanjiWrite;
|
||||
export type QueuedWrite =
|
||||
| QueuedTelemetryWrite
|
||||
| QueuedEventWrite
|
||||
| QueuedWordWrite
|
||||
| QueuedKanjiWrite;
|
||||
|
||||
export interface VideoMetadata {
|
||||
sourceType: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from './overlay-shortcut';
|
||||
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
||||
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
||||
export {
|
||||
copyCurrentSubtitle,
|
||||
|
||||
@@ -13,6 +13,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-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: () => {
|
||||
calls.push('subsync');
|
||||
@@ -30,6 +32,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
mpvPlayNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
mpvSendCommand: (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}']);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
isMpvConnected: () => false,
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||
REPLAY_SUBTITLE: string;
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||
};
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -19,6 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
showMpvOsd: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
mpvSendCommand: (command: (string | number)[]) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
@@ -46,6 +49,9 @@ function resolveProxyCommandOsd(command: (string | number)[]): string | null {
|
||||
if (property === 'secondary-sid') {
|
||||
return 'Secondary subtitle track: ${secondary-sid}';
|
||||
}
|
||||
if (property === 'sub-delay') {
|
||||
return 'Subtitle delay: ${sub-delay}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -64,6 +70,20 @@ export function handleMpvCommandFromIpc(
|
||||
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 (!options.hasRuntimeOptionsManager()) return;
|
||||
const [, idToken, directionToken] = first.split(':');
|
||||
|
||||
75
src/core/services/jlpt-vocab.test.ts
Normal file
75
src/core/services/jlpt-vocab.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as fs from 'fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { JlptLevel } from '../../types';
|
||||
@@ -24,6 +24,17 @@ const JLPT_LEVEL_PRECEDENCE: Record<JlptLevel, number> = {
|
||||
};
|
||||
|
||||
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 {
|
||||
return value.trim();
|
||||
@@ -36,12 +47,12 @@ function hasFrequencyDisplayValue(meta: unknown): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(frequency as Record<string, unknown>, 'displayValue');
|
||||
}
|
||||
|
||||
function addEntriesToMap(
|
||||
async function addEntriesToMap(
|
||||
rawEntries: unknown,
|
||||
level: JlptLevel,
|
||||
terms: Map<string, JlptLevel>,
|
||||
log: (message: string) => void,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const shouldUpdateLevel = (
|
||||
existingLevel: JlptLevel | undefined,
|
||||
incomingLevel: JlptLevel,
|
||||
@@ -53,7 +64,13 @@ function addEntriesToMap(
|
||||
return;
|
||||
}
|
||||
|
||||
let processedCount = 0;
|
||||
for (const rawEntry of rawEntries) {
|
||||
processedCount += 1;
|
||||
if (processedCount % ENTRY_YIELD_INTERVAL === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawEntry)) {
|
||||
continue;
|
||||
}
|
||||
@@ -84,22 +101,31 @@ function addEntriesToMap(
|
||||
}
|
||||
}
|
||||
|
||||
function collectDictionaryFromPath(
|
||||
async function collectDictionaryFromPath(
|
||||
dictionaryPath: string,
|
||||
log: (message: string) => void,
|
||||
): Map<string, JlptLevel> {
|
||||
): Promise<Map<string, JlptLevel>> {
|
||||
const terms = new Map<string, JlptLevel>();
|
||||
|
||||
for (const bank of JLPT_BANK_FILES) {
|
||||
const bankPath = path.join(dictionaryPath, bank.filename);
|
||||
if (!fs.existsSync(bankPath)) {
|
||||
log(`JLPT bank file missing for ${bank.level}: ${bankPath}`);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fs.readFileSync(bankPath, 'utf-8');
|
||||
rawText = await fs.readFile(bankPath, 'utf-8');
|
||||
} catch {
|
||||
log(`Failed to read JLPT bank file ${bankPath}`);
|
||||
continue;
|
||||
@@ -107,6 +133,7 @@ function collectDictionaryFromPath(
|
||||
|
||||
let rawEntries: unknown;
|
||||
try {
|
||||
await yieldToEventLoop();
|
||||
rawEntries = JSON.parse(rawText) as unknown;
|
||||
} catch {
|
||||
log(`Failed to parse JLPT bank file as JSON: ${bankPath}`);
|
||||
@@ -119,7 +146,7 @@ function collectDictionaryFromPath(
|
||||
}
|
||||
|
||||
const beforeSize = terms.size;
|
||||
addEntriesToMap(rawEntries, bank.level, terms, log);
|
||||
await addEntriesToMap(rawEntries, bank.level, terms, log);
|
||||
if (terms.size === beforeSize) {
|
||||
log(`JLPT bank file contained no extractable entries: ${bankPath}`);
|
||||
}
|
||||
@@ -137,17 +164,21 @@ export async function createJlptVocabularyLookup(
|
||||
const resolvedBanks: string[] = [];
|
||||
for (const dictionaryPath of options.searchPaths) {
|
||||
attemptedPaths.push(dictionaryPath);
|
||||
if (!fs.existsSync(dictionaryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.statSync(dictionaryPath).isDirectory()) {
|
||||
let isDirectory = false;
|
||||
try {
|
||||
isDirectory = (await fs.stat(dictionaryPath)).isDirectory();
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, 'ENOENT')) {
|
||||
continue;
|
||||
}
|
||||
options.log(`Failed to inspect JLPT dictionary path ${dictionaryPath}: ${String(error)}`);
|
||||
continue;
|
||||
}
|
||||
if (!isDirectory) continue;
|
||||
|
||||
foundDictionaryPathCount += 1;
|
||||
|
||||
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
|
||||
const terms = await collectDictionaryFromPath(dictionaryPath, options.log);
|
||||
if (terms.size > 0) {
|
||||
resolvedBanks.push(dictionaryPath);
|
||||
foundBankCount += 1;
|
||||
|
||||
@@ -57,6 +57,26 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub
|
||||
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', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -134,6 +134,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
private firstConnection = true;
|
||||
private hasConnectedOnce = false;
|
||||
public currentVideoPath = '';
|
||||
public currentMediaTitle: string | null = null;
|
||||
public currentTimePos = 0;
|
||||
public currentSubStart = 0;
|
||||
public currentSubEnd = 0;
|
||||
@@ -330,6 +331,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.emit('media-path-change', payload);
|
||||
},
|
||||
emitMediaTitleChange: (payload) => {
|
||||
this.currentMediaTitle = payload.title;
|
||||
this.emit('media-title-change', payload);
|
||||
},
|
||||
emitSubtitleMetricsChange: (patch) => {
|
||||
@@ -364,6 +366,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
},
|
||||
setCurrentVideoPath: (value: string) => {
|
||||
this.currentVideoPath = value;
|
||||
this.currentMediaTitle = null;
|
||||
},
|
||||
emitSecondarySubtitleVisibility: (payload) => {
|
||||
this.emit('secondary-subtitle-visibility', payload);
|
||||
|
||||
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
122
src/core/services/subtitle-delay-shift.test.ts
Normal 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/);
|
||||
});
|
||||
203
src/core/services/subtitle-delay-shift.ts
Normal file
203
src/core/services/subtitle-delay-shift.ts
Normal 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}');
|
||||
};
|
||||
}
|
||||
@@ -297,6 +297,97 @@ test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in p
|
||||
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 () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'鍛えた',
|
||||
@@ -309,6 +400,11 @@ test('tokenizeSubtitle queries headword frequencies with token reading for disam
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
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":"きた"')) {
|
||||
return [];
|
||||
}
|
||||
@@ -351,6 +447,58 @@ test('tokenizeSubtitle queries headword frequencies with token reading for disam
|
||||
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 () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'無人',
|
||||
@@ -2014,6 +2162,48 @@ test('createTokenizerDepsRuntime checks MeCab availability before first tokenize
|
||||
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 () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
@@ -2180,7 +2370,6 @@ test('tokenizeSubtitle keeps frequency enrichment while n+1 is disabled', async
|
||||
assert.equal(frequencyCalls, 1);
|
||||
});
|
||||
|
||||
|
||||
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and frequency annotations', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'になれば',
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
|
||||
getYomitanGroupDebugEnabled?: () => boolean;
|
||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||
enrichTokensWithMecab?: MecabTokenEnrichmentFn;
|
||||
onTokenizationReady?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface MecabTokenizerLike {
|
||||
@@ -78,6 +79,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
getMinSentenceWordsForNPlusOne?: () => number;
|
||||
getYomitanGroupDebugEnabled?: () => boolean;
|
||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||
onTokenizationReady?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface TokenizerAnnotationOptions {
|
||||
@@ -90,13 +92,14 @@ interface TokenizerAnnotationOptions {
|
||||
pos2Exclusions: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
let parserEnrichmentWorkerRuntimeModulePromise:
|
||||
| Promise<typeof import('./tokenizer/parser-enrichment-worker-runtime')>
|
||||
| null = null;
|
||||
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null = null;
|
||||
let parserEnrichmentFallbackModulePromise:
|
||||
| Promise<typeof import('./tokenizer/parser-enrichment-stage')>
|
||||
| null = null;
|
||||
let parserEnrichmentWorkerRuntimeModulePromise: Promise<
|
||||
typeof import('./tokenizer/parser-enrichment-worker-runtime')
|
||||
> | null = null;
|
||||
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null =
|
||||
null;
|
||||
let parserEnrichmentFallbackModulePromise: Promise<
|
||||
typeof import('./tokenizer/parser-enrichment-stage')
|
||||
> | null = null;
|
||||
const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
|
||||
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
||||
);
|
||||
@@ -104,7 +107,10 @@ const DEFAULT_ANNOTATION_POS2_EXCLUSIONS = resolveAnnotationPos2ExclusionSet(
|
||||
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) {
|
||||
return () => false;
|
||||
}
|
||||
@@ -124,7 +130,8 @@ async function enrichTokensWithMecabAsync(
|
||||
mecabTokens: MergedToken[] | null,
|
||||
): Promise<MergedToken[]> {
|
||||
if (!parserEnrichmentWorkerRuntimeModulePromise) {
|
||||
parserEnrichmentWorkerRuntimeModulePromise = import('./tokenizer/parser-enrichment-worker-runtime');
|
||||
parserEnrichmentWorkerRuntimeModulePromise =
|
||||
import('./tokenizer/parser-enrichment-worker-runtime');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -183,8 +190,7 @@ export function createTokenizerDepsRuntime(
|
||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||
getFrequencyDictionaryMatchMode:
|
||||
options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||
getFrequencyRank: options.getFrequencyRank,
|
||||
getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3),
|
||||
getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false),
|
||||
@@ -211,11 +217,11 @@ export function createTokenizerDepsRuntime(
|
||||
return null;
|
||||
}
|
||||
|
||||
const isKnownWordLookup = options.getNPlusOneEnabled?.() === false ? () => false : options.isKnownWord;
|
||||
return mergeTokens(rawTokens, isKnownWordLookup, options.getKnownWordMatchMode());
|
||||
return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode(), false);
|
||||
},
|
||||
enrichTokensWithMecab: async (tokens, mecabTokens) =>
|
||||
enrichTokensWithMecabAsync(tokens, mecabTokens),
|
||||
onTokenizationReady: options.onTokenizationReady,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +255,50 @@ function normalizeFrequencyLookupText(rawText: string): string {
|
||||
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(
|
||||
token: MergedToken,
|
||||
matchMode: FrequencyDictionaryMatchMode,
|
||||
@@ -276,17 +326,19 @@ function buildYomitanFrequencyTermReadingList(
|
||||
tokens: MergedToken[],
|
||||
matchMode: FrequencyDictionaryMatchMode,
|
||||
): Array<{ term: string; reading: string | null }> {
|
||||
return tokens
|
||||
.map((token) => {
|
||||
const term = resolveFrequencyLookupText(token, matchMode).trim();
|
||||
if (!term) {
|
||||
return null;
|
||||
}
|
||||
const readingRaw =
|
||||
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
return { term, reading: readingRaw };
|
||||
})
|
||||
.filter((pair): pair is { term: string; reading: string | null } => pair !== null);
|
||||
const termReadingList: Array<{ term: string; reading: string | null }> = [];
|
||||
for (const token of tokens) {
|
||||
const term = resolveFrequencyLookupText(token, matchMode).trim();
|
||||
if (!term) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const readingRaw =
|
||||
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
termReadingList.push({ term, reading: readingRaw });
|
||||
}
|
||||
|
||||
return termReadingList;
|
||||
}
|
||||
|
||||
function buildYomitanFrequencyRankMap(
|
||||
@@ -300,7 +352,8 @@ function buildYomitanFrequencyRankMap(
|
||||
continue;
|
||||
}
|
||||
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))
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const current = rankByTerm.get(normalizedTerm);
|
||||
@@ -427,19 +480,25 @@ async function parseWithYomitanInternalParser(
|
||||
if (!selectedTokens || selectedTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(selectedTokens);
|
||||
|
||||
if (deps.getYomitanGroupDebugEnabled?.() === true) {
|
||||
logSelectedYomitanGroups(text, selectedTokens);
|
||||
logSelectedYomitanGroups(text, normalizedSelectedTokens);
|
||||
}
|
||||
deps.onTokenizationReady?.(text);
|
||||
|
||||
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled
|
||||
? (async () => {
|
||||
const frequencyMatchMode = options.frequencyMatchMode;
|
||||
const termReadingList = buildYomitanFrequencyTermReadingList(
|
||||
selectedTokens,
|
||||
normalizedSelectedTokens,
|
||||
frequencyMatchMode,
|
||||
);
|
||||
const yomitanFrequencies = await requestYomitanTermFrequencies(termReadingList, deps, logger);
|
||||
const yomitanFrequencies = await requestYomitanTermFrequencies(
|
||||
termReadingList,
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return buildYomitanFrequencyRankMap(yomitanFrequencies);
|
||||
})()
|
||||
: Promise.resolve(new Map<string, number>());
|
||||
@@ -449,19 +508,19 @@ async function parseWithYomitanInternalParser(
|
||||
try {
|
||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
||||
return await enrichTokensWithMecab(selectedTokens, mecabTokens);
|
||||
return await enrichTokensWithMecab(normalizedSelectedTokens, mecabTokens);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.warn(
|
||||
'Failed to enrich Yomitan tokens with MeCab POS:',
|
||||
error.message,
|
||||
`tokenCount=${selectedTokens.length}`,
|
||||
`tokenCount=${normalizedSelectedTokens.length}`,
|
||||
`textLength=${text.length}`,
|
||||
);
|
||||
return selectedTokens;
|
||||
return normalizedSelectedTokens;
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(selectedTokens);
|
||||
: Promise.resolve(normalizedSelectedTokens);
|
||||
|
||||
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([
|
||||
frequencyRankPromise,
|
||||
|
||||
@@ -48,3 +48,77 @@ test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are n
|
||||
const emptyResult = enrichTokensWithMecabPos1(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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,120 @@ type MecabPosMetadata = {
|
||||
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 {
|
||||
const unique: string[] = [];
|
||||
for (const value of values) {
|
||||
@@ -29,87 +143,129 @@ function joinUniqueTags(values: Array<string | undefined>): string | undefined {
|
||||
return unique.join('|');
|
||||
}
|
||||
|
||||
function pickClosestMecabPosMetadata(
|
||||
function pickClosestMecabPosMetadataBySurface(
|
||||
token: MergedToken,
|
||||
mecabTokens: MergedToken[],
|
||||
candidates: IndexedMecabToken[] | undefined,
|
||||
): MecabPosMetadata | null {
|
||||
if (mecabTokens.length === 0) {
|
||||
if (!candidates || candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenStart = token.startPos ?? 0;
|
||||
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 bestSurfaceMatchEndDistance = Number.MAX_SAFE_INTEGER;
|
||||
let bestSurfaceMatchIndex = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const mecabToken of mecabTokens) {
|
||||
if (!mecabToken.pos1) {
|
||||
continue;
|
||||
const nearestStartIndex = lowerBoundByStart(candidates, tokenStart);
|
||||
let left = nearestStartIndex - 1;
|
||||
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) {
|
||||
continue;
|
||||
if (leftDistance === nearestDistance && left >= 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
const mecabStart = mecabToken.startPos ?? 0;
|
||||
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
|
||||
const startDistance = Math.abs(mecabStart - tokenStart);
|
||||
const endDistance = Math.abs(mecabEnd - tokenEnd);
|
||||
|
||||
if (
|
||||
startDistance < bestSurfaceMatchDistance ||
|
||||
(startDistance === bestSurfaceMatchDistance && endDistance < bestSurfaceMatchEndDistance)
|
||||
) {
|
||||
bestSurfaceMatchDistance = startDistance;
|
||||
bestSurfaceMatchEndDistance = endDistance;
|
||||
bestSurfaceMatchToken = mecabToken;
|
||||
if (rightDistance === nearestDistance && right < candidates.length) {
|
||||
const candidate = candidates[right]!;
|
||||
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;
|
||||
}
|
||||
right += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSurfaceMatchToken) {
|
||||
if (bestSurfaceMatchToken !== null) {
|
||||
return {
|
||||
pos1: bestSurfaceMatchToken.pos1 as string,
|
||||
pos1: bestSurfaceMatchToken.pos1,
|
||||
pos2: bestSurfaceMatchToken.pos2,
|
||||
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 bestSpan = 0;
|
||||
let bestStartDistance = 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) {
|
||||
if (!mecabToken.pos1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mecabStart = mecabToken.startPos ?? 0;
|
||||
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
|
||||
for (const candidate of candidates) {
|
||||
const mecabStart = candidate.start;
|
||||
const mecabEnd = candidate.end;
|
||||
const overlapStart = Math.max(tokenStart, mecabStart);
|
||||
const overlapEnd = Math.min(tokenEnd, mecabEnd);
|
||||
const overlap = Math.max(0, overlapEnd - overlapStart);
|
||||
if (overlap === 0) {
|
||||
continue;
|
||||
}
|
||||
overlappingTokens.push(mecabToken);
|
||||
overlappingTokens.push(candidate);
|
||||
|
||||
const span = mecabEnd - mecabStart;
|
||||
const startDistance = Math.abs(mecabStart - tokenStart);
|
||||
if (
|
||||
overlap > bestOverlap ||
|
||||
(overlap === bestOverlap &&
|
||||
(Math.abs(mecabStart - tokenStart) < bestStartDistance ||
|
||||
(Math.abs(mecabStart - tokenStart) === bestStartDistance &&
|
||||
(span > bestSpan || (span === bestSpan && mecabStart < bestStart)))))
|
||||
(startDistance < bestStartDistance ||
|
||||
(startDistance === bestStartDistance &&
|
||||
(span > bestSpan ||
|
||||
(span === bestSpan &&
|
||||
(mecabStart < bestStart ||
|
||||
(mecabStart === bestStart && candidate.index < bestIndex)))))))
|
||||
) {
|
||||
bestOverlap = overlap;
|
||||
bestSpan = span;
|
||||
bestStartDistance = Math.abs(mecabStart - tokenStart);
|
||||
bestStartDistance = startDistance;
|
||||
bestStart = mecabStart;
|
||||
bestToken = mecabToken;
|
||||
bestIndex = candidate.index;
|
||||
bestToken = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +273,21 @@ function pickClosestMecabPosMetadata(
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlapPos1 = joinUniqueTags(overlappingTokens.map((token) => token.pos1));
|
||||
const overlapPos2 = joinUniqueTags(overlappingTokens.map((token) => token.pos2));
|
||||
const overlapPos3 = joinUniqueTags(overlappingTokens.map((token) => token.pos3));
|
||||
const overlappingTokensByMecabOrder = overlappingTokens
|
||||
.slice()
|
||||
.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 {
|
||||
pos1: overlapPos1 ?? (bestToken.pos1 as string),
|
||||
pos1: overlapPos1 ?? bestToken.pos1,
|
||||
pos2: overlapPos2 ?? bestToken.pos2,
|
||||
pos3: overlapPos3 ?? bestToken.pos3,
|
||||
};
|
||||
@@ -130,13 +295,9 @@ function pickClosestMecabPosMetadata(
|
||||
|
||||
function fillMissingPos1BySurfaceSequence(
|
||||
tokens: MergedToken[],
|
||||
mecabTokens: MergedToken[],
|
||||
byTrimmedSurface: Map<string, IndexedMecabToken[]>,
|
||||
): MergedToken[] {
|
||||
const indexedMecabTokens = mecabTokens
|
||||
.map((token, index) => ({ token, index }))
|
||||
.filter(({ token }) => token.pos1 && token.surface.trim().length > 0);
|
||||
|
||||
if (indexedMecabTokens.length === 0) {
|
||||
if (byTrimmedSurface.size === 0) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@@ -151,27 +312,13 @@ function fillMissingPos1BySurfaceSequence(
|
||||
return token;
|
||||
}
|
||||
|
||||
let best: { token: MergedToken; index: number } | null = null;
|
||||
for (const candidate of indexedMecabTokens) {
|
||||
if (candidate.token.surface !== surface) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.index < cursor) {
|
||||
continue;
|
||||
}
|
||||
best = { token: candidate.token, index: candidate.index };
|
||||
break;
|
||||
const candidates = byTrimmedSurface.get(surface);
|
||||
if (!candidates || candidates.length === 0) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
for (const candidate of indexedMecabTokens) {
|
||||
if (candidate.token.surface !== surface) {
|
||||
continue;
|
||||
}
|
||||
best = { token: candidate.token, index: candidate.index };
|
||||
break;
|
||||
}
|
||||
}
|
||||
const atOrAfterCursorIndex = lowerBoundByIndex(candidates, cursor);
|
||||
const best = candidates[atOrAfterCursorIndex] ?? candidates[0];
|
||||
|
||||
if (!best) {
|
||||
return token;
|
||||
@@ -180,13 +327,41 @@ function fillMissingPos1BySurfaceSequence(
|
||||
cursor = best.index + 1;
|
||||
return {
|
||||
...token,
|
||||
pos1: best.token.pos1,
|
||||
pos2: best.token.pos2,
|
||||
pos3: best.token.pos3,
|
||||
pos1: best.pos1,
|
||||
pos2: best.pos2,
|
||||
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(
|
||||
tokens: MergedToken[],
|
||||
mecabTokens: MergedToken[] | null,
|
||||
@@ -199,12 +374,36 @@ export function enrichTokensWithMecabPos1(
|
||||
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) {
|
||||
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) {
|
||||
return token;
|
||||
}
|
||||
@@ -217,5 +416,5 @@ export function enrichTokensWithMecabPos1(
|
||||
};
|
||||
});
|
||||
|
||||
return fillMissingPos1BySurfaceSequence(overlapEnriched, mecabTokens);
|
||||
return fillMissingPos1BySurfaceSequence(overlapEnriched, lookup.byTrimmedSurface);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
requestYomitanParseResults,
|
||||
requestYomitanTermFrequencies,
|
||||
syncYomitanDefaultAnkiServer,
|
||||
} from './yomitan-parser-runtime';
|
||||
@@ -43,15 +44,19 @@ test('syncYomitanDefaultAnkiServer updates default profile server when script re
|
||||
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 }));
|
||||
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,
|
||||
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 () => {
|
||||
@@ -152,6 +157,102 @@ test('requestYomitanTermFrequencies prefers primary rank from displayValue array
|
||||
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 () => {
|
||||
const scripts: string[] = [];
|
||||
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;
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,10 @@ interface YomitanProfileMetadata {
|
||||
|
||||
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
||||
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> {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
@@ -87,7 +90,7 @@ function parsePositiveFrequencyString(value: string): number | null {
|
||||
const chunks = numericPrefix.split(',');
|
||||
const normalizedNumber =
|
||||
chunks.length <= 1
|
||||
? chunks[0] ?? ''
|
||||
? (chunks[0] ?? '')
|
||||
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
|
||||
? chunks.join('')
|
||||
: (chunks[0] ?? '');
|
||||
@@ -145,11 +148,7 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const reading =
|
||||
value.reading === null
|
||||
? null
|
||||
: typeof value.reading === 'string'
|
||||
? value.reading
|
||||
: null;
|
||||
value.reading === null ? null : typeof value.reading === 'string' ? value.reading : null;
|
||||
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
|
||||
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 seen = new Set<string>();
|
||||
|
||||
@@ -174,7 +175,9 @@ function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): Yo
|
||||
continue;
|
||||
}
|
||||
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 ?? ''}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
@@ -298,7 +301,9 @@ function groupFrequencyEntriesByPair(
|
||||
const grouped = new Map<string, YomitanTermFrequency[]>();
|
||||
for (const entry of entries) {
|
||||
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 existing = grouped.get(key);
|
||||
if (existing) {
|
||||
@@ -529,7 +534,7 @@ export async function requestYomitanParseResults(
|
||||
optionsContext: { index: ${metadata.profileIndex} },
|
||||
scanLength: ${metadata.scanLength},
|
||||
useInternalParser: true,
|
||||
useMecabParser: true
|
||||
useMecabParser: false
|
||||
});
|
||||
})();
|
||||
`
|
||||
@@ -564,7 +569,7 @@ export async function requestYomitanParseResults(
|
||||
optionsContext: { index: profileIndex },
|
||||
scanLength,
|
||||
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(
|
||||
termReadingList: YomitanTermReadingPair[],
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
@@ -622,148 +765,83 @@ export async function requestYomitanTermFrequencies(
|
||||
return buildCachedResult();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
const fetchedEntries = await fetchYomitanTermFrequencies(
|
||||
parserWindow,
|
||||
missingTermReadingList,
|
||||
metadata,
|
||||
logger,
|
||||
);
|
||||
if (fetchedEntries === null) {
|
||||
return buildCachedResult();
|
||||
}
|
||||
|
||||
return await invoke("getTermFrequencies", {
|
||||
termReadingList: ${JSON.stringify(missingTermReadingList)},
|
||||
dictionaries: ${JSON.stringify(metadata.dictionaries)}
|
||||
});
|
||||
})();
|
||||
`;
|
||||
cacheFrequencyEntriesForPairs(frequencyCache, missingTermReadingList, fetchedEntries);
|
||||
|
||||
try {
|
||||
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
const fetchedEntries = Array.isArray(rawResult)
|
||||
? normalizeFrequencyEntriesWithPriority(rawResult, metadata.dictionaryPriorityByName)
|
||||
: [];
|
||||
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
|
||||
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
|
||||
const missingTerms = new Set(missingTermReadingList.map((pair) => pair.term));
|
||||
|
||||
for (const pair of missingTermReadingList) {
|
||||
const fallbackTermReadingList = normalizeTermReadingList(
|
||||
missingTermReadingList
|
||||
.filter((pair) => pair.reading !== null)
|
||||
.map((pair) => {
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
}
|
||||
const cachedEntries = frequencyCache.get(key);
|
||||
if (cachedEntries && cachedEntries.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
const fallbackKey = makeTermReadingCacheKey(pair.term, null);
|
||||
const cachedFallback = frequencyCache.get(fallbackKey);
|
||||
if (cachedFallback && cachedFallback.length > 0) {
|
||||
frequencyCache.set(key, cachedFallback);
|
||||
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) {
|
||||
if (pair.reading === null) {
|
||||
continue;
|
||||
}
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
const cachedEntries = frequencyCache.get(key);
|
||||
if (cachedEntries && cachedEntries.length > 0) {
|
||||
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(
|
||||
@@ -846,7 +924,11 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
const checkedWithoutUpdate =
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
(result as { updated?: unknown }).updated === false;
|
||||
return checkedWithoutUpdate;
|
||||
} catch (err) {
|
||||
logger.error('Failed to sync Yomitan default profile Anki server:', (err as Error).message);
|
||||
return false;
|
||||
|
||||
@@ -33,7 +33,13 @@ test('sanitizeBackgroundEnv marks background child and keeps warning suppression
|
||||
|
||||
test('shouldDetachBackgroundLaunch only for first background invocation', () => {
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], {}), true);
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }), false);
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
||||
assert.equal(
|
||||
shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--start'], {}), false);
|
||||
});
|
||||
|
||||
164
src/main.ts
164
src/main.ts
@@ -331,6 +331,7 @@ import {
|
||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||
createConfigHotReloadRuntime,
|
||||
createDiscordPresenceService,
|
||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
||||
createFieldGroupingOverlayRuntime,
|
||||
createOverlayContentMeasurementStore,
|
||||
createOverlayManager,
|
||||
@@ -853,21 +854,30 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
|
||||
function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
|
||||
function maybeSignalPluginAutoplayReady(
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
): void {
|
||||
if (!payload.text.trim()) {
|
||||
return;
|
||||
}
|
||||
const mediaPath = appState.currentMediaPath;
|
||||
if (!mediaPath) {
|
||||
return;
|
||||
}
|
||||
if (autoPlayReadySignalMediaPath === mediaPath) {
|
||||
const mediaPath =
|
||||
appState.currentMediaPath?.trim() ||
|
||||
appState.mpvClient?.currentVideoPath?.trim() ||
|
||||
'__unknown__';
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const allowDuplicateWhilePaused =
|
||||
options?.forceWhilePaused === true && appState.playbackPaused !== false;
|
||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||
return;
|
||||
}
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||
const signalPluginAutoplayReady = (): void => {
|
||||
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||
};
|
||||
signalPluginAutoplayReady();
|
||||
const isPlaybackPaused = async (client: {
|
||||
requestProperty: (property: string) => Promise<unknown>;
|
||||
}): Promise<boolean> => {
|
||||
@@ -882,7 +892,9 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
|
||||
if (typeof pauseProperty === 'number') {
|
||||
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) {
|
||||
logger.debug(
|
||||
`[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;
|
||||
};
|
||||
|
||||
// Fallback: unpause directly in case plugin readiness handler is unavailable/outdated.
|
||||
void (async () => {
|
||||
const mpvClient = appState.mpvClient;
|
||||
if (!mpvClient?.connected) {
|
||||
logger.debug('[autoplay-ready] skipped unpause fallback; mpv not connected');
|
||||
return;
|
||||
}
|
||||
// Fallback: repeatedly try to release pause for a short window in case startup
|
||||
// gate arming and tokenization-ready signal arrive out of order.
|
||||
const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const attemptRelease = (attempt: number): void => {
|
||||
void (async () => {
|
||||
if (
|
||||
autoPlayReadySignalMediaPath !== mediaPath ||
|
||||
playbackGeneration !== autoPlayReadySignalGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||
logger.debug(`[autoplay-ready] mpv paused before fallback for ${mediaPath}: ${shouldUnpause}`);
|
||||
|
||||
if (!shouldUnpause) {
|
||||
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;
|
||||
const mpvClient = appState.mpvClient;
|
||||
if (!mpvClient?.connected) {
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const followupClient = appState.mpvClient;
|
||||
if (!followupClient?.connected) {
|
||||
return;
|
||||
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||
logger.debug(
|
||||
`[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);
|
||||
if (!shouldUnpauseFollowup) {
|
||||
return;
|
||||
}
|
||||
followupClient.send({ command: ['set_property', 'pause', false] });
|
||||
})();
|
||||
}, 500);
|
||||
logger.debug('[autoplay-ready] issued direct mpv unpause fallback');
|
||||
})();
|
||||
signalPluginAutoplayReady();
|
||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
})();
|
||||
};
|
||||
attemptRelease(0);
|
||||
}
|
||||
|
||||
let appTray: Tray | null = null;
|
||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||
tokenizeSubtitle: async (text: string) => {
|
||||
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
|
||||
return null;
|
||||
}
|
||||
return await tokenizeSubtitle(text);
|
||||
},
|
||||
emitSubtitle: (payload) => {
|
||||
@@ -950,7 +959,6 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
maybeSignalPluginAutoplayReady(payload);
|
||||
},
|
||||
logDebug: (message) => {
|
||||
logger.debug(`[subtitle-processing] ${message}`);
|
||||
@@ -1353,6 +1361,23 @@ function getRuntimeBooleanOption(
|
||||
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 {
|
||||
getResolvedJellyfinConfig,
|
||||
getJellyfinClientInfo,
|
||||
@@ -2320,9 +2345,7 @@ const {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
updateCurrentMediaPath: (path) => {
|
||||
if (appState.currentMediaPath !== path) {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
}
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
}
|
||||
@@ -2428,6 +2451,9 @@ const {
|
||||
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
|
||||
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
onTokenizationReady: (text) => {
|
||||
maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true });
|
||||
},
|
||||
},
|
||||
createTokenizerRuntimeDeps: (deps) =>
|
||||
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
|
||||
@@ -2469,7 +2495,10 @@ const {
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
return startupWarmups.mecab;
|
||||
if (!startupWarmups.mecab) {
|
||||
return false;
|
||||
}
|
||||
return shouldInitializeMecabForAnnotations();
|
||||
},
|
||||
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
|
||||
shouldWarmupSubtitleDictionaries: () => {
|
||||
@@ -2609,7 +2638,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await syncYomitanDefaultAnkiServerCore(
|
||||
const synced = await syncYomitanDefaultAnkiServerCore(
|
||||
targetUrl,
|
||||
{
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
@@ -2636,8 +2665,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
logger.info(`Yomitan default profile Anki server set to ${targetUrl}`);
|
||||
if (synced) {
|
||||
lastSyncedYomitanAnkiServer = targetUrl;
|
||||
}
|
||||
}
|
||||
@@ -2925,6 +2953,30 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
||||
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 {
|
||||
handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler,
|
||||
runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler,
|
||||
@@ -2945,6 +2997,8 @@ const {
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
||||
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||
|
||||
@@ -180,6 +180,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
|
||||
@@ -328,6 +329,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||
mpvSendCommand: params.mpvSendCommand,
|
||||
isMpvConnected: params.isMpvConnected,
|
||||
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
showMpvOsd: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
@@ -29,6 +30,8 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
mpvSendCommand: deps.sendMpvCommand,
|
||||
isMpvConnected: deps.isMpvConnected,
|
||||
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
|
||||
|
||||
@@ -14,6 +14,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
isMpvConnected: () => false,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
|
||||
@@ -22,6 +22,14 @@ const BASE_METRICS: MpvSubtitleRenderMetrics = {
|
||||
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 () => {
|
||||
const calls: string[] = [];
|
||||
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.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']);
|
||||
});
|
||||
|
||||
@@ -133,15 +133,58 @@ export function composeMpvRuntimeHandlers<
|
||||
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
|
||||
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 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> => {
|
||||
if (tokenizationWarmupCompleted) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!tokenizationWarmupInFlight) {
|
||||
tokenizationWarmupInFlight = (async () => {
|
||||
await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded();
|
||||
if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) {
|
||||
await createMecabTokenizerAndCheck().catch(() => {});
|
||||
const warmupTasks: Promise<unknown>[] = [ensureTokenizationPrerequisites()];
|
||||
if (
|
||||
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(() => {
|
||||
tokenizationWarmupInFlight = null;
|
||||
});
|
||||
@@ -149,10 +192,21 @@ export function composeMpvRuntimeHandlers<
|
||||
return tokenizationWarmupInFlight;
|
||||
};
|
||||
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(
|
||||
text,
|
||||
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
|
||||
options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
|
||||
@@ -14,6 +14,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
|
||||
@@ -11,6 +11,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => false,
|
||||
@@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
deps.showMpvOsd('hello');
|
||||
deps.replayCurrentSubtitle();
|
||||
deps.playNextSubtitle();
|
||||
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||
deps.sendMpvCommand(['show-text', 'ok']);
|
||||
assert.equal(deps.isMpvConnected(), true);
|
||||
assert.equal(deps.hasRuntimeOptionsManager(), false);
|
||||
@@ -31,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'osd:hello',
|
||||
'replay',
|
||||
'next',
|
||||
'shift:next',
|
||||
'cmd:show-text:ok',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
||||
|
||||
@@ -54,7 +54,7 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
isForced?: boolean;
|
||||
isExternal?: boolean;
|
||||
deliveryUrl?: string | null;
|
||||
}>
|
||||
}>
|
||||
>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
||||
logInfo: (message: string) => void;
|
||||
|
||||
@@ -107,3 +107,43 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
assert.equal(reportPayloads.length, 1);
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,21 @@ type ActivePlaybackState = {
|
||||
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: {
|
||||
ensureMpvConnectedForPlayback: () => Promise<boolean>;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
@@ -78,7 +93,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
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) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const warnings: string[] = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
|
||||
@@ -27,8 +27,12 @@ type JellyfinConfigLike = {
|
||||
};
|
||||
|
||||
function asInteger(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
|
||||
return value;
|
||||
if (typeof value === 'number' && Number.isSafeInteger(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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
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 prewarm = createPrewarmSubtitleDictionariesMainHandler({
|
||||
@@ -181,7 +181,7 @@ test('dictionary prewarm does not show OSD when notifications are disabled', asy
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
|
||||
getMecabTokenizer: () => deps.getMecabTokenizer(),
|
||||
onTokenizationReady: (text: string) => deps.onTokenizationReady?.(text),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +82,6 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
let loadingOsdFrame = 0;
|
||||
let loadingOsdTimer: unknown = null;
|
||||
const showMpvOsd = deps.showMpvOsd;
|
||||
const shouldShowOsdNotification = deps.shouldShowOsdNotification ?? (() => false);
|
||||
const setIntervalHandler =
|
||||
deps.setInterval ??
|
||||
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
||||
@@ -91,7 +91,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
||||
const spinnerFrames = ['|', '/', '-', '\\'];
|
||||
|
||||
const beginLoadingOsd = (): boolean => {
|
||||
if (!showMpvOsd || !shouldShowOsdNotification()) {
|
||||
if (!showMpvOsd) {
|
||||
return false;
|
||||
}
|
||||
loadingOsdDepth += 1;
|
||||
|
||||
114
src/mecab-tokenizer.test.ts
Normal file
114
src/mecab-tokenizer.test.ts
Normal 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);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* 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 { createLogger } from './logger';
|
||||
|
||||
@@ -89,18 +89,59 @@ export function parseMecabLine(line: string): Token | null {
|
||||
export interface MecabTokenizerOptions {
|
||||
mecabCommand?: 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 {
|
||||
private static readonly DEFAULT_IDLE_SHUTDOWN_MS = 30_000;
|
||||
private static readonly MAX_RETRY_COUNT = 1;
|
||||
|
||||
private mecabPath: string | null = null;
|
||||
private mecabCommand: string;
|
||||
private dictionaryPath: string | null;
|
||||
private available: boolean = false;
|
||||
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 = {}) {
|
||||
this.mecabCommand = options.mecabCommand?.trim() || 'mecab';
|
||||
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> {
|
||||
@@ -108,9 +149,10 @@ export class MecabTokenizer {
|
||||
const command = this.mecabCommand;
|
||||
const result = command.includes('/')
|
||||
? command
|
||||
: execSync(`which ${command}`, { encoding: 'utf-8' }).trim();
|
||||
if (result) {
|
||||
this.mecabPath = result;
|
||||
: this.execSyncFn(`which ${command}`, { encoding: 'utf-8' });
|
||||
const resolvedPath = String(result).trim();
|
||||
if (resolvedPath) {
|
||||
this.mecabPath = resolvedPath;
|
||||
this.available = true;
|
||||
log.info('MeCab found at:', this.mecabPath);
|
||||
return true;
|
||||
@@ -119,81 +161,257 @@ export class MecabTokenizer {
|
||||
log.info('MeCab not found on system');
|
||||
}
|
||||
|
||||
this.stopPersistentProcess();
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
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 new Promise((resolve) => {
|
||||
const mecabArgs: string[] = [];
|
||||
if (this.dictionaryPath) {
|
||||
mecabArgs.push('-d', this.dictionaryPath);
|
||||
}
|
||||
const mecab = spawn(this.mecabPath ?? this.mecabCommand, mecabArgs, {
|
||||
this.clearIdleShutdownTimer();
|
||||
this.requestQueue.push({
|
||||
text: normalizedText,
|
||||
retryCount: 0,
|
||||
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'],
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Failed to spawn MeCab:', (error as Error).message);
|
||||
return false;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
if (!mecab.stdin || !mecab.stdout || !mecab.stderr) {
|
||||
log.error('Failed to spawn MeCab: missing stdio pipes');
|
||||
try {
|
||||
mecab.kill();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
mecab.stdout.on('data', (data: Buffer) => {
|
||||
stdout += 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();
|
||||
this.stdoutBuffer = '';
|
||||
mecab.stdout.on('data', (data: Buffer | string) => {
|
||||
this.handleStdoutChunk(data.toString());
|
||||
});
|
||||
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 {
|
||||
@@ -206,6 +424,25 @@ export class MecabTokenizer {
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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({
|
||||
isKnown: true,
|
||||
frequencyRank: 10,
|
||||
@@ -228,10 +228,12 @@ test('getFrequencyRankLabelForToken returns rank only for frequency-colored toke
|
||||
};
|
||||
const frequencyToken = createToken({ surface: '頻度', frequencyRank: 20 });
|
||||
const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 });
|
||||
const nPlusOneToken = createToken({ surface: '目標', isNPlusOneTarget: true, frequencyRank: 20 });
|
||||
const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 });
|
||||
|
||||
assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(nPlusOneToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null);
|
||||
});
|
||||
|
||||
|
||||
@@ -189,10 +189,6 @@ export function getFrequencyRankLabelForToken(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
): string | null {
|
||||
if (token.isNPlusOneTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedFrequencySettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
|
||||
@@ -168,6 +168,7 @@ export function mergeTokens(
|
||||
tokens: Token[],
|
||||
isKnownWord: (text: string) => boolean = () => false,
|
||||
knownWordMatchMode: 'headword' | 'surface' = 'headword',
|
||||
shouldLookupKnownWords = true,
|
||||
): MergedToken[] {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return [];
|
||||
@@ -176,6 +177,12 @@ export function mergeTokens(
|
||||
const result: MergedToken[] = [];
|
||||
let charOffset = 0;
|
||||
let lastStandaloneToken: Token | null = null;
|
||||
const resolveKnownMatch = (text: string | undefined): boolean => {
|
||||
if (!shouldLookupKnownWords || !text) {
|
||||
return false;
|
||||
}
|
||||
return isKnownWord(text);
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const start = charOffset;
|
||||
@@ -189,7 +196,6 @@ export function mergeTokens(
|
||||
}
|
||||
|
||||
const tokenReading = ignoreReading(token) ? '' : token.katakanaReading || token.word;
|
||||
|
||||
if (shouldMergeToken && result.length > 0) {
|
||||
const prev = result.pop()!;
|
||||
const mergedHeadword = prev.headword;
|
||||
@@ -210,7 +216,7 @@ export function mergeTokens(
|
||||
pos2: prev.pos2 ?? token.pos2,
|
||||
pos3: prev.pos3 ?? token.pos3,
|
||||
isMerged: true,
|
||||
isKnown: headwordForKnownMatch ? isKnownWord(headwordForKnownMatch) : false,
|
||||
isKnown: resolveKnownMatch(headwordForKnownMatch),
|
||||
isNPlusOneTarget: false,
|
||||
});
|
||||
} else {
|
||||
@@ -231,7 +237,7 @@ export function mergeTokens(
|
||||
pos2: token.pos2,
|
||||
pos3: token.pos3,
|
||||
isMerged: false,
|
||||
isKnown: headwordForKnownMatch ? isKnownWord(headwordForKnownMatch) : false,
|
||||
isKnown: resolveKnownMatch(headwordForKnownMatch),
|
||||
isNPlusOneTarget: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface NotificationOptions {
|
||||
export interface MpvClient {
|
||||
currentSubText: string;
|
||||
currentVideoPath: string;
|
||||
currentMediaTitle?: string | null;
|
||||
currentTimePos: number;
|
||||
currentSubStart: number;
|
||||
currentSubEnd: number;
|
||||
|
||||
Reference in New Issue
Block a user