21 Commits
v0.2.0 ... main

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

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

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

View File

@@ -278,11 +278,13 @@ jobs:
echo "$CHANGES" >> $GITHUB_OUTPUT echo "$CHANGES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release - name: Publish Release
uses: softprops/action-gh-release@v2 env:
with: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: ${{ steps.version.outputs.VERSION }} run: |
body: | set -euo pipefail
cat > release-body.md <<'EOF'
## Changes ## Changes
${{ steps.changelog.outputs.CHANGES }} ${{ steps.changelog.outputs.CHANGES }}
@@ -311,12 +313,35 @@ jobs:
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi` - macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
files: | EOF
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release-body.md \
--prerelease false
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release-body.md \
--prerelease false
fi
shopt -s nullglob
artifacts=(
release/*.AppImage release/*.AppImage
release/*.dmg release/*.dmg
release/*.zip release/*.zip
release/*.tar.gz release/*.tar.gz
release/SHA256SUMS.txt release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
draft: false )
prerelease: false
if [ "${#artifacts[@]}" -eq 0 ]; then
echo "No release artifacts found for upload."
exit 1
fi
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
---
id: TASK-79
title: 'Jimaku modal: auto-close after successful subtitle load'
status: Done
assignee: []
created_date: '2026-03-01 13:52'
updated_date: '2026-03-01 14:06'
labels: []
dependencies: []
priority: medium
ordinal: 10000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
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).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Successful subtitle file selection/download in Jimaku closes modal automatically.
- [x] #2 Existing error path keeps modal open and shows error.
- [x] #3 Regression test covers success auto-close behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed renderer Jimaku success flow to close modal immediately after successful `jimakuDownloadFile` result. Added regression test (`src/renderer/modals/jimaku.test.ts`) that reproduces keyboard file-selection success path and asserts modal close state + `notifyOverlayModalClosed('jimaku')` emission. Kept failure path unchanged.
Also wired new test into `test:core:src` and `test:core:dist` package scripts.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,50 @@
---
id: TASK-80
title: 'Jimaku download: rename subtitle to current video basename'
status: Done
assignee: []
created_date: '2026-03-01 14:17'
updated_date: '2026-03-01 14:19'
labels: []
dependencies: []
priority: medium
ordinal: 11000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Jimaku subtitle destination name uses current media basename plus `.ja` and subtitle extension.
- [x] #2 Existing duplicate filename conflict handling still works.
- [x] #3 Regression tests cover renamed destination path behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Jimaku download path generation now derives subtitle filename from currently playing media basename and keeps subtitle extension from Jimaku file (`anime.mkv` + `.srt` => `anime.ja.srt`). Added pure helper `buildJimakuSubtitleFilenameFromMediaPath` and routed IPC download flow through it before existing duplicate-path conflict handling. Added regression tests for local path, missing extension fallback, and remote URL media paths.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,7 +126,7 @@ Control the minimum log level for runtime output:
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------- | ----------------------------------- | ------------------------------------------------ | | ------- | ---------------------------------------- | --------------------------------------------------------- |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) | | `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
### Auto-Start Overlay ### Auto-Start Overlay
@@ -322,6 +322,7 @@ Set the initial vertical subtitle position (measured from the bottom of the scre
| Option | Values | Description | | Option | Values | Description |
| ---------- | ---------------- | ---------------------------------------------------------------------- | | ---------- | ---------------- | ---------------------------------------------------------------------- |
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) | | `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text. In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
### Secondary Subtitles ### Secondary Subtitles
@@ -365,7 +366,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:** **Default keybindings:**
| Key | Command | Description | | Key | Command | Description |
| ----------------- | ---------------------------- | ------------------------------------- | | -------------------- | ---------------------------- | ------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
@@ -375,6 +376,8 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | | `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | | `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | | `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | | `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | | `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv | | `KeyQ` | `["quit"]` | Quit mpv |
@@ -402,11 +405,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null } { "key": "Space", "command": null }
``` ```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value. **Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), SubMiner also shows an mpv OSD notification after the command runs. For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
**See `config.example.jsonc`** for more keybinding examples and configuration options. **See `config.example.jsonc`** for more keybinding examples and configuration options.
@@ -884,7 +887,7 @@ Launcher subcommands:
- `subminer jellyfin -l --server ... --username ... --password ...` logs in. - `subminer jellyfin -l --server ... --username ... --password ...` logs in.
- `subminer jellyfin --logout` clears stored credentials. - `subminer jellyfin --logout` clears stored credentials.
- `subminer jellyfin -p` opens play picker. - `subminer jellyfin -p` opens play picker.
- `subminer jellyfin -d` starts cast discovery mode. - `subminer jellyfin -d` starts cast discovery mode in background/tray mode.
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch. - These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide. See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.

View File

@@ -60,12 +60,18 @@ Launcher wrapper equivalent for interactive playback flow:
subminer jellyfin -p subminer jellyfin -p
``` ```
Launcher wrapper for Jellyfin cast discovery mode (foreground app process): Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
```bash ```bash
subminer jellyfin -d subminer jellyfin -d
``` ```
Stop discovery session/app:
```bash
subminer app --stop
```
`subminer jf ...` is an alias for `subminer jellyfin ...`. `subminer jf ...` is an alias for `subminer jellyfin ...`.
To clear saved session credentials: To clear saved session credentials:
@@ -80,6 +86,17 @@ subminer jellyfin --logout
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
``` ```
Optional listing controls:
- `--jellyfin-recursive=true|false` (default: true)
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
These are used by the launcher picker flow to:
- keep root search focused on shows/folders/movies (exclude episode rows)
- browse selected anime/show directories as folder-or-file lists
- recurse for playable files only after selecting a folder
5. Start playback: 5. Start playback:
```bash ```bash

View File

@@ -80,7 +80,7 @@ Use `subminer <subcommand> -h` for command-specific help.
## Options ## Options
| Flag | Description | | Flag | Description |
| ----------------------- | --------------------------------------------------- | | --------------------- | --------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) | | `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively | | `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf | | `-R, --rofi` | Use rofi instead of fzf |

View File

@@ -121,7 +121,7 @@ aniskip_button_duration=3
### Option Reference ### Option Reference
| Option | Default | Values | Description | | Option | Default | Values | Description |
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | | ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary | | `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path | | `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | | `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |

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

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

View File

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

View File

@@ -55,7 +55,8 @@ subminer jellyfin # Open Jellyfin setup window (subcommand form)
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret' subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
subminer jellyfin --logout # Clear stored Jellyfin token/session data subminer jellyfin --logout # Clear stored Jellyfin token/session data
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
subminer jellyfin -d # Jellyfin cast-discovery mode (foreground app) subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
subminer doctor # Dependency + config + socket diagnostics subminer doctor # Dependency + config + socket diagnostics
subminer config path # Print active config path subminer config path # Print active config path
subminer config show # Print active config contents subminer config show # Print active config contents

View File

@@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
} }
if (args.jellyfinDiscovery) { if (args.jellyfinDiscovery) {
const forwarded = ['--start']; const forwarded = ['--background', '--jellyfin-remote-announce'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import type { import type {
Args, Args,
@@ -8,8 +9,8 @@ import type {
JellyfinItemEntry, JellyfinItemEntry,
JellyfinGroupEntry, JellyfinGroupEntry,
} from './types.js'; } from './types.js';
import { log, fail } from './log.js'; import { log, fail, getMpvLogPath } from './log.js';
import { commandExists, resolvePathMaybe } from './util.js'; import { commandExists, resolvePathMaybe, sleep } from './util.js';
import { import {
pickLibrary, pickLibrary,
pickItem, pickItem,
@@ -18,12 +19,17 @@ import {
findRofiTheme, findRofiTheme,
} from './picker.js'; } from './picker.js';
import { loadLauncherJellyfinConfig } from './config.js'; import { loadLauncherJellyfinConfig } from './config.js';
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
import { import {
runAppCommandWithInheritLogged, runAppCommandWithInheritLogged,
runAppCommandCaptureOutput,
launchAppStartDetached,
launchMpvIdleDetached, launchMpvIdleDetached,
waitForUnixSocketReady, waitForUnixSocketReady,
} from './mpv.js'; } from './mpv.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
export function sanitizeServerUrl(value: string): string { export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
} }
@@ -114,6 +120,606 @@ export function formatJellyfinItemDisplay(item: Record<string, unknown>): string
return `${name} (${type})`; return `${name} (${type})`;
} }
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, '');
}
function parseNamedJellyfinRecord(payload: string): {
name: string;
id: string;
type: string;
} | null {
const typeClose = payload.lastIndexOf(')');
if (typeClose !== payload.length - 1) return null;
const typeOpen = payload.lastIndexOf(' (');
if (typeOpen <= 0 || typeOpen >= typeClose) return null;
const idClose = payload.lastIndexOf(']', typeOpen);
if (idClose <= 0) return null;
const idOpen = payload.lastIndexOf(' [', idClose);
if (idOpen <= 0 || idOpen >= idClose) return null;
const name = payload.slice(0, idOpen).trim();
const id = payload.slice(idOpen + 2, idClose).trim();
const type = payload.slice(typeOpen + 2, typeClose).trim();
if (!name || !id || !type) return null;
return { name, id, type };
}
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
const libraries: JellyfinLibraryEntry[] = [];
const seenIds = new Set<string>();
for (const rawLine of output.split(/\r?\n/)) {
const line = stripAnsi(rawLine);
const markerIndex = line.indexOf('Jellyfin library:');
if (markerIndex < 0) continue;
const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim();
const parsed = parseNamedJellyfinRecord(payload);
if (!parsed || seenIds.has(parsed.id)) continue;
seenIds.add(parsed.id);
libraries.push({
id: parsed.id,
name: parsed.name,
kind: parsed.type,
});
}
return libraries;
}
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
const items: JellyfinItemEntry[] = [];
const seenIds = new Set<string>();
for (const rawLine of output.split(/\r?\n/)) {
const line = stripAnsi(rawLine);
const markerIndex = line.indexOf('Jellyfin item:');
if (markerIndex < 0) continue;
const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim();
const parsed = parseNamedJellyfinRecord(payload);
if (!parsed || seenIds.has(parsed.id)) continue;
seenIds.add(parsed.id);
items.push({
id: parsed.id,
name: parsed.name,
type: parsed.type,
display: parsed.name,
});
}
return items;
}
export function parseJellyfinErrorFromAppOutput(output: string): string {
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i];
if (!line) continue;
const bracketedErrorIndex = line.indexOf('[ERROR]');
if (bracketedErrorIndex >= 0) {
const message = line.slice(bracketedErrorIndex + '[ERROR]'.length).trim();
if (message.length > 0) return message;
}
const mainErrorIndex = line.indexOf(' - ERROR - ');
if (mainErrorIndex >= 0) {
const message = line.slice(mainErrorIndex + ' - ERROR - '.length).trim();
if (message.length > 0) return message;
}
if (line.includes('Missing Jellyfin session')) {
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
}
}
return '';
}
type JellyfinPreviewAuthResponse = {
serverUrl: string;
accessToken: string;
userId: string;
};
export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null {
if (!raw || raw.trim().length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const candidate = parsed as Record<string, unknown>;
const serverUrl = sanitizeServerUrl(
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
);
const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
if (!serverUrl || !accessToken) return null;
return {
serverUrl,
accessToken,
userId,
};
}
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
return errorMessage.includes('No running instance. Use --start to launch the app.');
}
export function deriveJellyfinTokenStorePath(configPath: string): string {
return path.join(path.dirname(configPath), 'jellyfin-token-store.json');
}
export function hasStoredJellyfinSession(
configPath: string,
exists: (candidate: string) => boolean = fs.existsSync,
): boolean {
return exists(deriveJellyfinTokenStorePath(configPath));
}
export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string {
try {
const buffer = fs.readFileSync(logPath);
if (buffer.length === 0) return '';
const normalizedOffset =
Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
return buffer.subarray(startOffset).toString('utf8');
} catch {
return '';
}
}
export function parseEpisodePathFromDisplay(
display: string,
): { seriesName: string; seasonNumber: number } | null {
const normalized = display.trim().replace(/\s+/g, ' ');
const match = normalized.match(/^(.*?)\s+S(\d{1,2})E\d{1,3}\b/i);
if (!match) return null;
const seriesName = match[1].trim();
const seasonNumber = Number.parseInt(match[2], 10);
if (!seriesName || !Number.isFinite(seasonNumber) || seasonNumber < 0) return null;
return { seriesName, seasonNumber };
}
function normalizeJellyfinType(type: string): string {
return type.trim().toLowerCase();
}
export function isJellyfinPlayableType(type: string): boolean {
const normalizedType = normalizeJellyfinType(type);
return (
normalizedType === 'movie' ||
normalizedType === 'episode' ||
normalizedType === 'audio' ||
normalizedType === 'video' ||
normalizedType === 'musicvideo'
);
}
export function isJellyfinContainerType(type: string): boolean {
const normalizedType = normalizeJellyfinType(type);
return (
normalizedType === 'series' ||
normalizedType === 'season' ||
normalizedType === 'folder' ||
normalizedType === 'collectionfolder'
);
}
function isJellyfinRootSearchType(type: string): boolean {
const normalizedType = normalizeJellyfinType(type);
return (
isJellyfinContainerType(normalizedType) ||
normalizedType === 'movie' ||
normalizedType === 'video' ||
normalizedType === 'musicvideo'
);
}
export function buildRootSearchGroups(items: JellyfinItemEntry[]): JellyfinGroupEntry[] {
const seenIds = new Set<string>();
const groups: JellyfinGroupEntry[] = [];
for (const item of items) {
if (!item.id || seenIds.has(item.id) || !isJellyfinRootSearchType(item.type)) continue;
seenIds.add(item.id);
groups.push({
id: item.id,
name: item.name,
type: item.type,
display: `${item.name} (${item.type})`,
});
}
return groups;
}
export type JellyfinChildSelection =
| { kind: 'playable'; id: string }
| { kind: 'container'; id: string };
export function classifyJellyfinChildSelection(
selectedChild: Pick<JellyfinGroupEntry, 'id' | 'type'>,
): JellyfinChildSelection {
if (isJellyfinPlayableType(selectedChild.type)) {
return { kind: 'playable', id: selectedChild.id };
}
if (isJellyfinContainerType(selectedChild.type)) {
return { kind: 'container', id: selectedChild.id };
}
fail('Selected Jellyfin item is not playable.');
}
async function runAppJellyfinListCommand(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<string> {
const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label);
if (attempt.status !== 0) {
const message = attempt.output.trim();
fail(message || `${label} failed.`);
}
if (attempt.error) {
fail(attempt.error);
}
return attempt.output;
}
async function runAppJellyfinCommand(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = [...appArgs];
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwardedBase.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwardedBase.push('--password-store', args.passwordStore);
}
const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath();
return readUtf8FileAppendedSince(logPath, offset);
};
const hasCommandSignal = (output: string): boolean => {
if (label === 'jellyfin-libraries') {
return (
output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.')
);
}
if (label === 'jellyfin-items') {
return (
output.includes('Jellyfin item:') ||
output.includes('No Jellyfin items found for the selected library/search.')
);
}
if (label === 'jellyfin-preview-auth') {
return output.includes('Jellyfin preview auth written.');
}
return output.trim().length > 0;
};
const runOnce = (): { status: number; output: string; error: string; logOffset: number } => {
const forwarded = [...forwardedBase];
const logPath = getMpvLogPath();
let logOffset = 0;
try {
if (fs.existsSync(logPath)) {
logOffset = fs.statSync(logPath).size;
}
} catch {
logOffset = 0;
}
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
const result = runAppCommandCaptureOutput(appPath, forwarded);
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
let error = parseJellyfinErrorFromAppOutput(output);
return { status: result.status, output, error, logOffset };
};
let retriedAfterStart = false;
let attempt = runOnce();
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
log('debug', args.logLevel, `${label}: starting app detached, then retrying command`);
launchAppStartDetached(appPath, args.logLevel);
await sleep(1000);
retriedAfterStart = true;
attempt = runOnce();
}
if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) {
// When app is already running, command handling happens in the primary process and log
// lines can land slightly after the helper process exits.
const settleWindowMs = (() => {
if (label === 'jellyfin-items') {
return retriedAfterStart ? 45000 : 30000;
}
return retriedAfterStart ? 12000 : 4000;
})();
const settleDeadline = Date.now() + settleWindowMs;
const settleOffset = attempt.logOffset;
while (Date.now() < settleDeadline) {
await sleep(100);
const settledOutput = readLogAppendedSince(settleOffset);
if (!settledOutput.trim()) {
continue;
}
attempt.output = `${attempt.output}\n${settledOutput}`;
attempt.error = parseJellyfinErrorFromAppOutput(attempt.output);
if (attempt.error || hasCommandSignal(attempt.output)) {
break;
}
}
}
return attempt;
}
async function requestJellyfinPreviewAuthFromApp(
appPath: string,
args: Args,
): Promise<JellyfinPreviewAuthResponse | null> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-'));
const responsePath = path.join(tmpDir, 'response.json');
try {
const attempt = await runAppJellyfinCommand(
appPath,
args,
['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`],
'jellyfin-preview-auth',
);
if (attempt.status !== 0 || attempt.error) {
return null;
}
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
try {
if (fs.existsSync(responsePath)) {
const raw = fs.readFileSync(responsePath, 'utf8');
const parsed = parseJellyfinPreviewAuthResponse(raw);
if (parsed) {
return parsed;
}
}
} catch {
// retry until timeout
}
await sleep(100);
}
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
}
async function resolveJellyfinSelectionViaApp(
appPath: string,
args: Args,
session: JellyfinSessionConfig,
themePath: string | null = null,
): Promise<string> {
const listLibrariesOutput = await runAppJellyfinListCommand(
appPath,
args,
['--jellyfin-libraries'],
'jellyfin-libraries',
);
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
if (libraries.length === 0) {
fail('No Jellyfin libraries found.');
}
const iconlessSession: JellyfinSessionConfig = {
...session,
userId: session.userId || 'launcher',
};
const noIcon = (): string | null => null;
const hasPreviewSession = Boolean(iconlessSession.serverUrl && iconlessSession.accessToken);
const pickerSession: JellyfinSessionConfig = {
...iconlessSession,
pullPictures: hasPreviewSession && iconlessSession.pullPictures === true,
};
const ensureIconForPicker = hasPreviewSession ? ensureJellyfinIcon : noIcon;
if (!hasPreviewSession) {
log(
'debug',
args.logLevel,
'Jellyfin picker image previews disabled (no launcher-accessible Jellyfin token).',
);
}
const configuredDefaultLibraryId = session.defaultLibraryId;
const hasConfiguredDefault = libraries.some(
(library) => library.id === configuredDefaultLibraryId,
);
let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : '';
if (!libraryId) {
libraryId = pickLibrary(
pickerSession,
libraries,
args.useRofi,
ensureIconForPicker,
'',
themePath,
);
if (!libraryId) fail('No Jellyfin library selected.');
}
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
const normalizedSearch = searchTerm.trim();
const searchLimit = 400;
const browseLimit = 2500;
const rootIncludeItemTypes = 'Series,Season,Folder,CollectionFolder,Movie,Video,MusicVideo';
const directoryIncludeItemTypes =
'Series,Season,Folder,CollectionFolder,Movie,Episode,Audio,Video,MusicVideo';
const recursivePlayableIncludeItemTypes = 'Movie,Episode,Audio,Video,MusicVideo';
const listItemsViaApp = async (
parentId: string,
options: {
search?: string;
limit: number;
recursive?: boolean;
includeItemTypes?: string;
},
): Promise<JellyfinItemEntry[]> => {
const itemArgs = [
'--jellyfin-items',
`--jellyfin-library-id=${parentId}`,
`--jellyfin-limit=${Math.max(1, options.limit)}`,
];
const normalized = (options.search || '').trim();
if (normalized.length > 0) {
itemArgs.push(`--jellyfin-search=${normalized}`);
}
if (typeof options.recursive === 'boolean') {
itemArgs.push(`--jellyfin-recursive=${options.recursive ? 'true' : 'false'}`);
}
const includeItemTypes = options.includeItemTypes?.trim();
if (includeItemTypes) {
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
}
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
return parseJellyfinItemsFromAppOutput(output);
};
let rootItems =
normalizedSearch.length > 0
? await listItemsViaApp(libraryId, {
search: normalizedSearch,
limit: searchLimit,
recursive: true,
includeItemTypes: rootIncludeItemTypes,
})
: await listItemsViaApp(libraryId, {
limit: browseLimit,
recursive: false,
includeItemTypes: rootIncludeItemTypes,
});
if (normalizedSearch.length > 0 && rootItems.length === 0) {
// Compatibility fallback for older app binaries that may ignore custom search include types.
log(
'debug',
args.logLevel,
`jellyfin-items: no direct search hits for "${normalizedSearch}", falling back to unfiltered library query`,
);
rootItems = await listItemsViaApp(libraryId, {
limit: browseLimit,
recursive: false,
includeItemTypes: rootIncludeItemTypes,
});
}
const rootGroups = buildRootSearchGroups(rootItems);
if (rootGroups.length === 0) {
fail('No Jellyfin shows or movies found.');
}
const rootById = new Map(rootGroups.map((group) => [group.id, group]));
const selectedRootId = pickGroup(
pickerSession,
rootGroups,
args.useRofi,
ensureIconForPicker,
normalizedSearch,
themePath,
);
if (!selectedRootId) fail('No Jellyfin show/movie selected.');
const selectedRoot = rootById.get(selectedRootId);
if (!selectedRoot) fail('Invalid Jellyfin root selection.');
if (isJellyfinPlayableType(selectedRoot.type)) {
return selectedRoot.id;
}
const pickPlayableDescendants = async (parentId: string): Promise<string> => {
const descendantItems = await listItemsViaApp(parentId, {
limit: browseLimit,
recursive: true,
includeItemTypes: recursivePlayableIncludeItemTypes,
});
const playableItems = descendantItems.filter((item) => isJellyfinPlayableType(item.type));
if (playableItems.length === 0) {
fail('No playable Jellyfin items found.');
}
const selectedItemId = pickItem(
pickerSession,
playableItems,
args.useRofi,
ensureIconForPicker,
'',
themePath,
);
if (!selectedItemId) {
fail('No Jellyfin item selected.');
}
return selectedItemId;
};
let currentContainerId = selectedRoot.id;
while (true) {
const directoryEntries = await listItemsViaApp(currentContainerId, {
limit: browseLimit,
recursive: false,
includeItemTypes: directoryIncludeItemTypes,
});
const seenIds = new Set<string>();
const childGroups: JellyfinGroupEntry[] = [];
for (const item of directoryEntries) {
if (!item.id || seenIds.has(item.id)) continue;
if (!isJellyfinContainerType(item.type) && !isJellyfinPlayableType(item.type)) continue;
seenIds.add(item.id);
childGroups.push({
id: item.id,
name: item.name,
type: item.type,
display: `${item.name} (${item.type})`,
});
}
if (childGroups.length === 0) {
return await pickPlayableDescendants(currentContainerId);
}
const childById = new Map(childGroups.map((group) => [group.id, group]));
const selectedChildId = pickGroup(
pickerSession,
childGroups,
args.useRofi,
ensureIconForPicker,
'',
themePath,
);
if (!selectedChildId) fail('No Jellyfin folder/file selected.');
const selectedChild = childById.get(selectedChildId);
if (!selectedChild) fail('Invalid Jellyfin item selection.');
const selection = classifyJellyfinChildSelection(selectedChild);
if (selection.kind === 'playable') {
return selection.id;
}
currentContainerId = selection.id;
}
}
export async function resolveJellyfinSelection( export async function resolveJellyfinSelection(
args: Args, args: Args,
session: JellyfinSessionConfig, session: JellyfinSessionConfig,
@@ -367,18 +973,37 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '', iconCacheDir: config.iconCacheDir || '',
}; };
if (!session.serverUrl || !session.accessToken || !session.userId) {
fail(
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
}
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null; const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) { if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.'); log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
} }
const itemId = await resolveJellyfinSelection(args, session, rofiTheme); const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = '';
if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
} else {
const configPath = resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) {
fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
);
}
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
} else {
log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
);
}
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
}
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false; let mpvReady = false;
@@ -393,7 +1018,7 @@ export async function runJellyfinPlayMenu(
if (!mpvReady) { if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`); fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
} }
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId]; const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');

View File

@@ -5,6 +5,19 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { resolveConfigFilePath } from '../src/config/path-resolution.js'; import { resolveConfigFilePath } from '../src/config/path-resolution.js';
import {
parseJellyfinLibrariesFromAppOutput,
parseJellyfinItemsFromAppOutput,
parseJellyfinErrorFromAppOutput,
parseJellyfinPreviewAuthResponse,
deriveJellyfinTokenStorePath,
hasStoredJellyfinSession,
shouldRetryWithStartForNoRunningInstance,
readUtf8FileAppendedSince,
parseEpisodePathFromDisplay,
buildRootSearchGroups,
classifyJellyfinChildSelection,
} from './jellyfin.js';
type RunResult = { type RunResult = {
status: number | null; status: number | null;
@@ -149,7 +162,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
}); });
}); });
test('jellyfin discovery routes to app --start with log-level forwarding', () => { test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
@@ -169,7 +182,37 @@ test('jellyfin discovery routes to app --start with log-level forwarding', () =>
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env); const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0); assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n'); assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
);
});
});
test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
);
}); });
}); });
@@ -238,3 +281,182 @@ test('jellyfin setup forwards password-store to app command', () => {
); );
}); });
}); });
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
const parsed = parseJellyfinLibrariesFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
`);
assert.deepEqual(parsed, [
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
{ id: 'lib2', name: 'Movies', kind: 'movies' },
]);
});
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
const parsed = parseJellyfinItemsFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
`);
assert.deepEqual(parsed, [
{
id: 'item-10',
name: 'Solo Leveling S01E10',
type: 'Episode',
display: 'Solo Leveling S01E10',
},
{
id: 'movie-1',
name: 'Movie [Alt]',
type: 'Movie',
display: 'Movie [Alt]',
},
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
`);
assert.equal(
parsed,
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
});
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[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."}',
);
});
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
const parsed = parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096/',
accessToken: 'token-123',
userId: 'user-1',
}),
);
assert.deepEqual(parsed, {
serverUrl: 'http://pve-main:8096',
accessToken: 'token-123',
userId: 'user-1',
});
});
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
assert.equal(
parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096',
accessToken: '',
userId: 'user-1',
}),
),
null,
);
});
test('deriveJellyfinTokenStorePath resolves alongside config path', () => {
const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc');
assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json');
});
test('hasStoredJellyfinSession checks token-store existence', () => {
const exists = (candidate: string): boolean =>
candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json';
assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true);
assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false);
});
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
assert.equal(
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
true,
);
assert.equal(
shouldRetryWithStartForNoRunningInstance(
'Missing Jellyfin session. Run --jellyfin-login first.',
),
false,
);
});
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
withTempDir((root) => {
const logPath = path.join(root, 'SubMiner.log');
const prefix = '[subminer] こんにちは\n';
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
const byteOffset = Buffer.byteLength(prefix, 'utf8');
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
});
});
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('Frieren S2E10 Something'), {
seriesName: 'Frieren',
seasonNumber: 2,
});
});
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
});
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
const groups = buildRootSearchGroups([
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
]);
assert.deepEqual(groups, [
{
id: 'series-1',
name: 'The Eminence in Shadow',
type: 'Series',
display: 'The Eminence in Shadow (Series)',
},
{
id: 'movie-1',
name: 'Spirited Away',
type: 'Movie',
display: 'Spirited Away (Movie)',
},
]);
});
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
assert.deepEqual(next, {
kind: 'container',
id: 'season-2',
});
});

View File

@@ -5,7 +5,7 @@ import path from 'node:path';
import net from 'node:net'; import net from 'node:net';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import type { Args } from './types'; import type { Args } from './types';
import { startOverlay, state, waitForUnixSocketReady } from './mpv'; import { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv';
import * as mpvModule from './mpv'; import * as mpvModule from './mpv';
function createTempSocketPath(): { dir: string; socketPath: string } { function createTempSocketPath(): { dir: string; socketPath: string } {
@@ -19,6 +19,18 @@ test('mpv module exposes only canonical socket readiness helper', () => {
assert.equal('waitForSocket' in mpvModule, false); assert.equal('waitForSocket' in mpvModule, false);
}); });
test('runAppCommandCaptureOutput captures status and stdio', () => {
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write("stdout-line"); process.stderr.write("stderr-line");',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, 'stdout-line');
assert.equal(result.stderr, 'stderr-line');
assert.equal(result.error, undefined);
});
test('waitForUnixSocketReady returns false when socket never appears', async () => { test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
try { try {

View File

@@ -658,6 +658,28 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): ne
process.exit(result.status ?? 0); process.exit(result.status ?? 0);
} }
export function runAppCommandCaptureOutput(
appPath: string,
appArgs: string[],
): {
status: number;
stdout: string;
stderr: string;
error?: Error;
} {
const result = spawnSync(appPath, appArgs, {
env: buildAppEnv(),
encoding: 'utf8',
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error ?? undefined,
};
}
export function runAppCommandWithInheritLogged( export function runAppCommandWithInheritLogged(
appPath: string, appPath: string,
appArgs: string[], appArgs: string[],

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.2.0", "version": "0.2.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -23,8 +23,8 @@
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
@@ -58,6 +58,7 @@
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
"@plausible-analytics/tracker": "^0.4.4",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
"discord-rpc": "^4.0.1", "discord-rpc": "^4.0.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args'; import {
hasExplicitCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
} from './args';
test('parseArgs parses booleans and value flags', () => { test('parseArgs parses booleans and value flags', () => {
const args = parseArgs([ const args = parseArgs([
@@ -42,6 +47,30 @@ test('parseArgs ignores missing value after --log-level', () => {
assert.equal(args.start, true); assert.equal(args.start, true);
}); });
test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([
'--jellyfin-items',
'--jellyfin-recursive=false',
'--jellyfin-include-item-types',
'Series,Movie,Folder',
]);
assert.equal(args.jellyfinItems, true);
assert.equal(args.jellyfinRecursive, false);
assert.equal(args.jellyfinIncludeItemTypes, 'Series,Movie,Folder');
});
test('parseArgs handles space-separated jellyfin recursive control', () => {
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', 'false']);
assert.equal(args.jellyfinRecursive, false);
});
test('parseArgs ignores unrecognized space-separated jellyfin recursive values', () => {
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', '--start']);
assert.equal(args.jellyfinRecursive, undefined);
assert.equal(args.start, true);
});
test('hasExplicitCommand and shouldStartApp preserve command intent', () => { test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const stopOnly = parseArgs(['--stop']); const stopOnly = parseArgs(['--stop']);
assert.equal(hasExplicitCommand(stopOnly), true); assert.equal(hasExplicitCommand(stopOnly), true);
@@ -118,6 +147,16 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true); assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false); assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
const jellyfinPreviewAuth = parseArgs([
'--jellyfin-preview-auth',
'--jellyfin-response-path',
'/tmp/subminer-jf-response.json',
]);
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
assert.equal(jellyfinPreviewAuth.jellyfinResponsePath, '/tmp/subminer-jf-response.json');
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
assert.equal(shouldStartApp(jellyfinPreviewAuth), false);
const background = parseArgs(['--background']); const background = parseArgs(['--background']);
assert.equal(background.background, true); assert.equal(background.background, true);
assert.equal(hasExplicitCommand(background), true); assert.equal(hasExplicitCommand(background), true);

View File

@@ -33,6 +33,7 @@ export interface CliArgs {
jellyfinSubtitleUrlsOnly: boolean; jellyfinSubtitleUrlsOnly: boolean;
jellyfinPlay: boolean; jellyfinPlay: boolean;
jellyfinRemoteAnnounce: boolean; jellyfinRemoteAnnounce: boolean;
jellyfinPreviewAuth: boolean;
texthooker: boolean; texthooker: boolean;
help: boolean; help: boolean;
autoStartOverlay: boolean; autoStartOverlay: boolean;
@@ -49,8 +50,11 @@ export interface CliArgs {
jellyfinItemId?: string; jellyfinItemId?: string;
jellyfinSearch?: string; jellyfinSearch?: string;
jellyfinLimit?: number; jellyfinLimit?: number;
jellyfinRecursive?: boolean;
jellyfinIncludeItemTypes?: string;
jellyfinAudioStreamIndex?: number; jellyfinAudioStreamIndex?: number;
jellyfinSubtitleStreamIndex?: number; jellyfinSubtitleStreamIndex?: number;
jellyfinResponsePath?: string;
debug: boolean; debug: boolean;
logLevel?: 'debug' | 'info' | 'warn' | 'error'; logLevel?: 'debug' | 'info' | 'warn' | 'error';
} }
@@ -93,6 +97,7 @@ export function parseArgs(argv: string[]): CliArgs {
jellyfinSubtitleUrlsOnly: false, jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false, jellyfinPlay: false,
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
@@ -147,6 +152,7 @@ export function parseArgs(argv: string[]): CliArgs {
args.jellyfinSubtitleUrlsOnly = true; args.jellyfinSubtitleUrlsOnly = true;
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true; } else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true; else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true; else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true; else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--generate-config') args.generateConfig = true; else if (arg === '--generate-config') args.generateConfig = true;
@@ -229,6 +235,27 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--jellyfin-limit') { } else if (arg === '--jellyfin-limit') {
const value = Number(readValue(argv[i + 1])); const value = Number(readValue(argv[i + 1]));
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value); if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
} else if (arg.startsWith('--jellyfin-recursive=')) {
const value = arg.split('=', 2)[1]?.trim().toLowerCase();
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();
if (value === 'false' || value === '0' || value === 'no') {
args.jellyfinRecursive = false;
} else if (value === 'true' || value === '1' || value === 'yes') {
args.jellyfinRecursive = true;
}
} else if (arg === '--jellyfin-non-recursive') {
args.jellyfinRecursive = false;
} else if (arg.startsWith('--jellyfin-include-item-types=')) {
const value = arg.split('=', 2)[1];
if (value) args.jellyfinIncludeItemTypes = value;
} else if (arg === '--jellyfin-include-item-types') {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinIncludeItemTypes = value;
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) { } else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
const value = Number(arg.split('=', 2)[1]); const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value; if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
@@ -241,6 +268,12 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--jellyfin-subtitle-stream-index') { } else if (arg === '--jellyfin-subtitle-stream-index') {
const value = Number(readValue(argv[i + 1])); const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value; if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
} else if (arg.startsWith('--jellyfin-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.jellyfinResponsePath = value;
} else if (arg === '--jellyfin-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinResponsePath = value;
} }
} }
@@ -282,6 +315,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinSubtitles || args.jellyfinSubtitles ||
args.jellyfinPlay || args.jellyfinPlay ||
args.jellyfinRemoteAnnounce || args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker || args.texthooker ||
args.generateConfig || args.generateConfig ||
args.help args.help
@@ -350,6 +384,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinSubtitles && !args.jellyfinSubtitles &&
!args.jellyfinPlay && !args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce && !args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.texthooker && !args.texthooker &&
!args.help && !args.help &&
!args.autoStartOverlay && !args.autoStartOverlay &&

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
parseKikuFieldGroupingChoice, parseKikuFieldGroupingChoice,
parseKikuMergePreviewRequest, parseKikuMergePreviewRequest,
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
const logger = createLogger('main:anki-jimaku-ipc'); const logger = createLogger('main:anki-jimaku-ipc');
@@ -148,10 +149,11 @@ export function registerAnkiJimakuIpcHandlers(
if (!safeName) { if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } }; return { ok: false, error: { error: 'Invalid subtitle filename.' } };
} }
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName);
const ext = path.extname(safeName); const ext = path.extname(subtitleFilename);
const baseName = ext ? safeName.slice(0, -ext.length) : safeName; const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
let targetPath = path.join(mediaDir, safeName); let targetPath = path.join(mediaDir, subtitleFilename);
if (fs.existsSync(targetPath)) { if (fs.existsSync(targetPath)) {
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`); targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
let counter = 2; let counter = 2;

View File

@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CliArgs } from '../../cli/args';
import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
settings: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
refreshKnownWords: false,
toggleSecondarySub: false,
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
debug: false,
...overrides,
};
}
function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
const calls: string[] = [];
let lockCalls = 0;
const deps: AppLifecycleServiceDeps = {
shouldStartApp: () => false,
parseArgs: () => makeArgs(),
requestSingleInstanceLock: () => {
lockCalls += 1;
return true;
},
quitApp: () => {
calls.push('quitApp');
},
onSecondInstance: () => {},
handleCliCommand: () => {},
printHelp: () => {
calls.push('printHelp');
},
logNoRunningInstance: () => {
calls.push('logNoRunningInstance');
},
whenReady: () => {},
onWindowAllClosed: () => {},
onWillQuit: () => {},
onActivate: () => {},
isDarwinPlatform: () => false,
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => true,
...overrides,
};
return { deps, calls, getLockCalls: () => lockCalls };
}
test('startAppLifecycle handles --help without acquiring single-instance lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
});
startAppLifecycle(makeArgs({ help: true }), deps);
assert.equal(getLockCalls(), 0);
assert.deepEqual(calls, ['printHelp', 'quitApp']);
});
test('startAppLifecycle still acquires lock for startup commands', () => {
const { deps, getLockCalls } = createDeps({
shouldStartApp: () => true,
whenReady: () => {},
});
startAppLifecycle(makeArgs({ start: true }), deps);
assert.equal(getLockCalls(), 1);
});

View File

@@ -87,6 +87,12 @@ export function createAppLifecycleDepsRuntime(
} }
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void { export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
deps.printHelp();
deps.quitApp();
return;
}
const gotTheLock = deps.requestSingleInstanceLock(); const gotTheLock = deps.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
deps.quitApp(); deps.quitApp();
@@ -101,12 +107,6 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} }
}); });
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
deps.printHelp();
deps.quitApp();
return;
}
if (!deps.shouldStartApp(initialArgs)) { if (!deps.shouldStartApp(initialArgs)) {
if (initialArgs.stop && !initialArgs.start) { if (initialArgs.stop && !initialArgs.start) {
deps.quitApp(); deps.quitApp();

View File

@@ -111,8 +111,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('logConfigWarning'), false); assert.equal(calls.includes('logConfigWarning'), false);
assert.equal(calls.includes('handleInitialArgs'), true); assert.equal(calls.includes('handleInitialArgs'), true);
assert.equal(calls.includes('loadYomitanExtension'), true); assert.equal(calls.includes('loadYomitanExtension'), true);
assert.equal(calls[0], 'loadYomitanExtension'); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
assert.equal(calls[calls.length - 1], 'handleInitialArgs');
}); });
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {

View File

@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinSubtitleUrlsOnly: false, jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false, jellyfinPlay: false,
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,14 +20,7 @@ export function startSessionRecord(
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, nowMs);
sessionUuid,
videoId,
startedAtMs,
SESSION_STATUS_ACTIVE,
startedAtMs,
nowMs,
);
const sessionId = Number(result.lastInsertRowid); const sessionId = Number(result.lastInsertRowid);
return { return {
sessionId, sessionId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,6 +87,10 @@ test('listItems supports search and formats title', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => { globalThis.fetch = (async (input) => {
assert.match(String(input), /SearchTerm=planet/); assert.match(String(input), /SearchTerm=planet/);
assert.match(
String(input),
/IncludeItemTypes=Movie%2CEpisode%2CAudio%2CSeries%2CSeason%2CFolder%2CCollectionFolder/,
);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
Items: [ Items: [
@@ -125,6 +129,64 @@ test('listItems supports search and formats title', async () => {
} }
}); });
test('listItems keeps playable-only include types when search is empty', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /IncludeItemTypes=Movie%2CEpisode%2CAudio/);
assert.doesNotMatch(String(input), /CollectionFolder|Series|Season|Folder/);
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
}) as typeof fetch;
try {
const items = await listItems(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
libraryId: 'lib-1',
limit: 25,
},
);
assert.deepEqual(items, []);
} finally {
globalThis.fetch = originalFetch;
}
});
test('listItems accepts explicit include types and recursive mode', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /Recursive=false/);
assert.match(String(input), /IncludeItemTypes=Series%2CMovie%2CFolder/);
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
}) as typeof fetch;
try {
const items = await listItems(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
libraryId: 'lib-1',
includeItemTypes: 'Series,Movie,Folder',
recursive: false,
limit: 25,
},
);
assert.deepEqual(items, []);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan chooses direct play when allowed', async () => { test('resolvePlaybackPlan chooses direct play when allowed', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => globalThis.fetch = (async () =>

View File

@@ -370,21 +370,29 @@ export async function listItems(
libraryId: string; libraryId: string;
searchTerm?: string; searchTerm?: string;
limit?: number; limit?: number;
recursive?: boolean;
includeItemTypes?: string;
}, },
): Promise<Array<{ id: string; name: string; type: string; title: string }>> { ): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
if (!options.libraryId) throw new Error('Missing Jellyfin library id.'); if (!options.libraryId) throw new Error('Missing Jellyfin library id.');
const normalizedSearchTerm = options.searchTerm?.trim() || '';
const includeItemTypes =
options.includeItemTypes?.trim() ||
(normalizedSearchTerm
? 'Movie,Episode,Audio,Series,Season,Folder,CollectionFolder'
: 'Movie,Episode,Audio');
const query = new URLSearchParams({ const query = new URLSearchParams({
ParentId: options.libraryId, ParentId: options.libraryId,
Recursive: 'true', Recursive: options.recursive === false ? 'false' : 'true',
IncludeItemTypes: 'Movie,Episode,Audio', IncludeItemTypes: includeItemTypes,
Fields: 'MediaSources,UserData', Fields: 'MediaSources,UserData',
SortBy: 'SortName', SortBy: 'SortName',
SortOrder: 'Ascending', SortOrder: 'Ascending',
Limit: String(options.limit ?? 100), Limit: String(options.limit ?? 100),
}); });
if (options.searchTerm?.trim()) { if (normalizedSearchTerm) {
query.set('SearchTerm', options.searchTerm.trim()); query.set('SearchTerm', normalizedSearchTerm);
} }
const payload = await jellyfinRequestJson<JellyfinItemsResponse>( const payload = await jellyfinRequestJson<JellyfinItemsResponse>(

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path.js';
test('buildJimakuSubtitleFilenameFromMediaPath uses media basename + ja + subtitle extension', () => {
assert.equal(
buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs.Release.1080p.srt'),
'anime.ja.srt',
);
});
test('buildJimakuSubtitleFilenameFromMediaPath falls back to .srt when subtitle name has no extension', () => {
assert.equal(
buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs Release'),
'anime.ja.srt',
);
});
test('buildJimakuSubtitleFilenameFromMediaPath supports remote media URLs', () => {
assert.equal(
buildJimakuSubtitleFilenameFromMediaPath(
'https://cdn.example.org/library/Anime%20Episode%2001.mkv?token=abc',
'anything.ass',
),
'Anime Episode 01.ja.ass',
);
});

View File

@@ -0,0 +1,51 @@
import * as path from 'node:path';
const DEFAULT_JIMAKU_LANGUAGE_SUFFIX = 'ja';
const DEFAULT_SUBTITLE_EXTENSION = '.srt';
function stripFileExtension(name: string): string {
const ext = path.extname(name);
return ext ? name.slice(0, -ext.length) : name;
}
function sanitizeFilenameSegment(value: string, fallback: string): string {
const sanitized = value
.replace(/[\\/:*?"<>|]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return sanitized || fallback;
}
function resolveMediaFilename(mediaPath: string): string {
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath)) {
return path.basename(path.resolve(mediaPath));
}
try {
const parsedUrl = new URL(mediaPath);
const decodedPath = decodeURIComponent(parsedUrl.pathname);
const fromPath = path.basename(decodedPath);
if (fromPath) {
return fromPath;
}
return parsedUrl.hostname.replace(/^www\./, '') || 'subtitle';
} catch {
return path.basename(mediaPath);
}
}
export function buildJimakuSubtitleFilenameFromMediaPath(
mediaPath: string,
downloadedSubtitleName: string,
languageSuffix = DEFAULT_JIMAKU_LANGUAGE_SUFFIX,
): string {
const mediaFilename = resolveMediaFilename(mediaPath);
const mediaBasename = sanitizeFilenameSegment(stripFileExtension(mediaFilename), 'subtitle');
const subtitleName = path.basename(downloadedSubtitleName);
const subtitleExt = path.extname(subtitleName) || DEFAULT_SUBTITLE_EXTENSION;
const normalizedLanguageSuffix = sanitizeFilenameSegment(languageSuffix, 'ja').replace(
/\s+/g,
'-',
);
return `${mediaBasename}.${normalizedLanguageSuffix}${subtitleExt}`;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinSubtitleUrlsOnly: false, jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false, jellyfinPlay: false,
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
getYomitanGroupDebugEnabled?: () => boolean; getYomitanGroupDebugEnabled?: () => boolean;
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>; tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
enrichTokensWithMecab?: MecabTokenEnrichmentFn; enrichTokensWithMecab?: MecabTokenEnrichmentFn;
onTokenizationReady?: (text: string) => void;
} }
interface MecabTokenizerLike { interface MecabTokenizerLike {
@@ -78,6 +79,7 @@ export interface TokenizerDepsRuntimeOptions {
getMinSentenceWordsForNPlusOne?: () => number; getMinSentenceWordsForNPlusOne?: () => number;
getYomitanGroupDebugEnabled?: () => boolean; getYomitanGroupDebugEnabled?: () => boolean;
getMecabTokenizer: () => MecabTokenizerLike | null; getMecabTokenizer: () => MecabTokenizerLike | null;
onTokenizationReady?: (text: string) => void;
} }
interface TokenizerAnnotationOptions { interface TokenizerAnnotationOptions {
@@ -90,13 +92,14 @@ interface TokenizerAnnotationOptions {
pos2Exclusions: ReadonlySet<string>; pos2Exclusions: ReadonlySet<string>;
} }
let parserEnrichmentWorkerRuntimeModulePromise: let parserEnrichmentWorkerRuntimeModulePromise: Promise<
| Promise<typeof import('./tokenizer/parser-enrichment-worker-runtime')> typeof import('./tokenizer/parser-enrichment-worker-runtime')
| null = null; > | null = null;
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null = null; let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null =
let parserEnrichmentFallbackModulePromise: null;
| Promise<typeof import('./tokenizer/parser-enrichment-stage')> let parserEnrichmentFallbackModulePromise: Promise<
| null = null; typeof import('./tokenizer/parser-enrichment-stage')
> | null = null;
const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet( const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG, DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
); );
@@ -104,7 +107,10 @@ const DEFAULT_ANNOTATION_POS2_EXCLUSIONS = resolveAnnotationPos2ExclusionSet(
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG, DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
); );
function getKnownWordLookup(deps: TokenizerServiceDeps, options: TokenizerAnnotationOptions): (text: string) => boolean { function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
if (!options.nPlusOneEnabled) { if (!options.nPlusOneEnabled) {
return () => false; return () => false;
} }
@@ -124,7 +130,8 @@ async function enrichTokensWithMecabAsync(
mecabTokens: MergedToken[] | null, mecabTokens: MergedToken[] | null,
): Promise<MergedToken[]> { ): Promise<MergedToken[]> {
if (!parserEnrichmentWorkerRuntimeModulePromise) { if (!parserEnrichmentWorkerRuntimeModulePromise) {
parserEnrichmentWorkerRuntimeModulePromise = import('./tokenizer/parser-enrichment-worker-runtime'); parserEnrichmentWorkerRuntimeModulePromise =
import('./tokenizer/parser-enrichment-worker-runtime');
} }
try { try {
@@ -183,8 +190,7 @@ export function createTokenizerDepsRuntime(
getNPlusOneEnabled: options.getNPlusOneEnabled, getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled, getJlptEnabled: options.getJlptEnabled,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled, getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyDictionaryMatchMode: getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
getFrequencyRank: options.getFrequencyRank, getFrequencyRank: options.getFrequencyRank,
getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3), getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3),
getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false), getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false),
@@ -211,11 +217,11 @@ export function createTokenizerDepsRuntime(
return null; return null;
} }
const isKnownWordLookup = options.getNPlusOneEnabled?.() === false ? () => false : options.isKnownWord; return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode(), false);
return mergeTokens(rawTokens, isKnownWordLookup, options.getKnownWordMatchMode());
}, },
enrichTokensWithMecab: async (tokens, mecabTokens) => enrichTokensWithMecab: async (tokens, mecabTokens) =>
enrichTokensWithMecabAsync(tokens, mecabTokens), enrichTokensWithMecabAsync(tokens, mecabTokens),
onTokenizationReady: options.onTokenizationReady,
}; };
} }
@@ -249,6 +255,50 @@ function normalizeFrequencyLookupText(rawText: string): string {
return rawText.trim().toLowerCase(); return rawText.trim().toLowerCase();
} }
function isKanaChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (
(code >= 0x3041 && code <= 0x3096) ||
(code >= 0x309b && code <= 0x309f) ||
(code >= 0x30a0 && code <= 0x30fa) ||
(code >= 0x30fd && code <= 0x30ff)
);
}
function getTrailingKanaSuffix(surface: string): string {
const chars = Array.from(surface);
let splitIndex = chars.length;
while (splitIndex > 0 && isKanaChar(chars[splitIndex - 1]!)) {
splitIndex -= 1;
}
if (splitIndex <= 0 || splitIndex >= chars.length) {
return '';
}
return chars.slice(splitIndex).join('');
}
function normalizeYomitanMergedReading(token: MergedToken): string {
const reading = token.reading ?? '';
if (!reading || token.headword !== token.surface) {
return reading;
}
const trailingKanaSuffix = getTrailingKanaSuffix(token.surface);
if (!trailingKanaSuffix || reading.endsWith(trailingKanaSuffix)) {
return reading;
}
return `${reading}${trailingKanaSuffix}`;
}
function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] {
return tokens.map((token) => ({
...token,
reading: normalizeYomitanMergedReading(token),
}));
}
function resolveFrequencyLookupText( function resolveFrequencyLookupText(
token: MergedToken, token: MergedToken,
matchMode: FrequencyDictionaryMatchMode, matchMode: FrequencyDictionaryMatchMode,
@@ -276,17 +326,19 @@ function buildYomitanFrequencyTermReadingList(
tokens: MergedToken[], tokens: MergedToken[],
matchMode: FrequencyDictionaryMatchMode, matchMode: FrequencyDictionaryMatchMode,
): Array<{ term: string; reading: string | null }> { ): Array<{ term: string; reading: string | null }> {
return tokens const termReadingList: Array<{ term: string; reading: string | null }> = [];
.map((token) => { for (const token of tokens) {
const term = resolveFrequencyLookupText(token, matchMode).trim(); const term = resolveFrequencyLookupText(token, matchMode).trim();
if (!term) { if (!term) {
return null; continue;
} }
const readingRaw = const readingRaw =
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null; token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
return { term, reading: readingRaw }; termReadingList.push({ term, reading: readingRaw });
}) }
.filter((pair): pair is { term: string; reading: string | null } => pair !== null);
return termReadingList;
} }
function buildYomitanFrequencyRankMap( function buildYomitanFrequencyRankMap(
@@ -300,7 +352,8 @@ function buildYomitanFrequencyRankMap(
continue; continue;
} }
const dictionaryPriority = const dictionaryPriority =
typeof frequency.dictionaryPriority === 'number' && Number.isFinite(frequency.dictionaryPriority) typeof frequency.dictionaryPriority === 'number' &&
Number.isFinite(frequency.dictionaryPriority)
? Math.max(0, Math.floor(frequency.dictionaryPriority)) ? Math.max(0, Math.floor(frequency.dictionaryPriority))
: Number.MAX_SAFE_INTEGER; : Number.MAX_SAFE_INTEGER;
const current = rankByTerm.get(normalizedTerm); const current = rankByTerm.get(normalizedTerm);
@@ -427,19 +480,25 @@ async function parseWithYomitanInternalParser(
if (!selectedTokens || selectedTokens.length === 0) { if (!selectedTokens || selectedTokens.length === 0) {
return null; return null;
} }
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(selectedTokens);
if (deps.getYomitanGroupDebugEnabled?.() === true) { if (deps.getYomitanGroupDebugEnabled?.() === true) {
logSelectedYomitanGroups(text, selectedTokens); logSelectedYomitanGroups(text, normalizedSelectedTokens);
} }
deps.onTokenizationReady?.(text);
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled
? (async () => { ? (async () => {
const frequencyMatchMode = options.frequencyMatchMode; const frequencyMatchMode = options.frequencyMatchMode;
const termReadingList = buildYomitanFrequencyTermReadingList( const termReadingList = buildYomitanFrequencyTermReadingList(
selectedTokens, normalizedSelectedTokens,
frequencyMatchMode, frequencyMatchMode,
); );
const yomitanFrequencies = await requestYomitanTermFrequencies(termReadingList, deps, logger); const yomitanFrequencies = await requestYomitanTermFrequencies(
termReadingList,
deps,
logger,
);
return buildYomitanFrequencyRankMap(yomitanFrequencies); return buildYomitanFrequencyRankMap(yomitanFrequencies);
})() })()
: Promise.resolve(new Map<string, number>()); : Promise.resolve(new Map<string, number>());
@@ -449,19 +508,19 @@ async function parseWithYomitanInternalParser(
try { try {
const mecabTokens = await deps.tokenizeWithMecab(text); const mecabTokens = await deps.tokenizeWithMecab(text);
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync; const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
return await enrichTokensWithMecab(selectedTokens, mecabTokens); return await enrichTokensWithMecab(normalizedSelectedTokens, mecabTokens);
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
logger.warn( logger.warn(
'Failed to enrich Yomitan tokens with MeCab POS:', 'Failed to enrich Yomitan tokens with MeCab POS:',
error.message, error.message,
`tokenCount=${selectedTokens.length}`, `tokenCount=${normalizedSelectedTokens.length}`,
`textLength=${text.length}`, `textLength=${text.length}`,
); );
return selectedTokens; return normalizedSelectedTokens;
} }
})() })()
: Promise.resolve(selectedTokens); : Promise.resolve(normalizedSelectedTokens);
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([ const [yomitanRankByTerm, enrichedTokens] = await Promise.all([
frequencyRankPromise, frequencyRankPromise,

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,10 @@ interface YomitanProfileMetadata {
const DEFAULT_YOMITAN_SCAN_LENGTH = 40; const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>(); const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
const yomitanFrequencyCacheByWindow = new WeakMap<BrowserWindow, Map<string, YomitanTermFrequency[]>>(); const yomitanFrequencyCacheByWindow = new WeakMap<
BrowserWindow,
Map<string, YomitanTermFrequency[]>
>();
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object'); return Boolean(value && typeof value === 'object');
@@ -87,7 +90,7 @@ function parsePositiveFrequencyString(value: string): number | null {
const chunks = numericPrefix.split(','); const chunks = numericPrefix.split(',');
const normalizedNumber = const normalizedNumber =
chunks.length <= 1 chunks.length <= 1
? chunks[0] ?? '' ? (chunks[0] ?? '')
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk)) : chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
? chunks.join('') ? chunks.join('')
: (chunks[0] ?? ''); : (chunks[0] ?? '');
@@ -145,11 +148,7 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
: Number.MAX_SAFE_INTEGER; : Number.MAX_SAFE_INTEGER;
const reading = const reading =
value.reading === null value.reading === null ? null : typeof value.reading === 'string' ? value.reading : null;
? null
: typeof value.reading === 'string'
? value.reading
: null;
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null; const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
const displayValueParsed = value.displayValueParsed === true; const displayValueParsed = value.displayValueParsed === true;
@@ -164,7 +163,9 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
}; };
} }
function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): YomitanTermReadingPair[] { function normalizeTermReadingList(
termReadingList: YomitanTermReadingPair[],
): YomitanTermReadingPair[] {
const normalized: YomitanTermReadingPair[] = []; const normalized: YomitanTermReadingPair[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -174,7 +175,9 @@ function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): Yo
continue; continue;
} }
const reading = const reading =
typeof pair.reading === 'string' && pair.reading.trim().length > 0 ? pair.reading.trim() : null; typeof pair.reading === 'string' && pair.reading.trim().length > 0
? pair.reading.trim()
: null;
const key = `${term}\u0000${reading ?? ''}`; const key = `${term}\u0000${reading ?? ''}`;
if (seen.has(key)) { if (seen.has(key)) {
continue; continue;
@@ -298,7 +301,9 @@ function groupFrequencyEntriesByPair(
const grouped = new Map<string, YomitanTermFrequency[]>(); const grouped = new Map<string, YomitanTermFrequency[]>();
for (const entry of entries) { for (const entry of entries) {
const reading = const reading =
typeof entry.reading === 'string' && entry.reading.trim().length > 0 ? entry.reading.trim() : null; typeof entry.reading === 'string' && entry.reading.trim().length > 0
? entry.reading.trim()
: null;
const key = makeTermReadingCacheKey(entry.term.trim(), reading); const key = makeTermReadingCacheKey(entry.term.trim(), reading);
const existing = grouped.get(key); const existing = grouped.get(key);
if (existing) { if (existing) {
@@ -529,7 +534,7 @@ export async function requestYomitanParseResults(
optionsContext: { index: ${metadata.profileIndex} }, optionsContext: { index: ${metadata.profileIndex} },
scanLength: ${metadata.scanLength}, scanLength: ${metadata.scanLength},
useInternalParser: true, useInternalParser: true,
useMecabParser: true useMecabParser: false
}); });
})(); })();
` `
@@ -564,7 +569,7 @@ export async function requestYomitanParseResults(
optionsContext: { index: profileIndex }, optionsContext: { index: profileIndex },
scanLength, scanLength,
useInternalParser: true, useInternalParser: true,
useMecabParser: true useMecabParser: false
}); });
})(); })();
`; `;
@@ -578,6 +583,144 @@ export async function requestYomitanParseResults(
} }
} }
async function fetchYomitanTermFrequencies(
parserWindow: BrowserWindow,
termReadingList: YomitanTermReadingPair[],
metadata: YomitanProfileMetadata | null,
logger: LoggerLike,
): Promise<YomitanTermFrequency[] | null> {
if (metadata && metadata.dictionaries.length > 0) {
const script = `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
return await invoke("getTermFrequencies", {
termReadingList: ${JSON.stringify(termReadingList)},
dictionaries: ${JSON.stringify(metadata.dictionaries)}
});
})();
`;
try {
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
return Array.isArray(rawResult)
? normalizeFrequencyEntriesWithPriority(rawResult, metadata.dictionaryPriorityByName)
: [];
} catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message);
return null;
}
}
const script = `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
const optionsFull = await invoke("optionsGetFull", undefined);
const profileIndex = optionsFull.profileCurrent;
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? [];
const dictionaryEntries = Array.isArray(dictionariesRaw)
? dictionariesRaw
.filter((entry) => entry && typeof entry === "object" && entry.enabled === true && typeof entry.name === "string")
.map((entry, index) => ({
name: entry.name,
id: typeof entry.id === "number" && Number.isFinite(entry.id) ? Math.floor(entry.id) : index
}))
.sort((a, b) => a.id - b.id)
: [];
const dictionaries = dictionaryEntries.map((entry) => entry.name);
const dictionaryPriorityByName = dictionaryEntries.reduce((acc, entry, index) => {
acc[entry.name] = index;
return acc;
}, {});
if (dictionaries.length === 0) {
return [];
}
const rawFrequencies = await invoke("getTermFrequencies", {
termReadingList: ${JSON.stringify(termReadingList)},
dictionaries
});
if (!Array.isArray(rawFrequencies)) {
return [];
}
return rawFrequencies
.filter((entry) => entry && typeof entry === "object")
.map((entry) => ({
...entry,
dictionaryPriority:
typeof entry.dictionary === "string" && dictionaryPriorityByName[entry.dictionary] !== undefined
? dictionaryPriorityByName[entry.dictionary]
: Number.MAX_SAFE_INTEGER
}));
})();
`;
try {
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
return Array.isArray(rawResult)
? rawResult
.map((entry) => toYomitanTermFrequency(entry))
.filter((entry): entry is YomitanTermFrequency => entry !== null)
: [];
} catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message);
return null;
}
}
function cacheFrequencyEntriesForPairs(
frequencyCache: Map<string, YomitanTermFrequency[]>,
termReadingList: YomitanTermReadingPair[],
fetchedEntries: YomitanTermFrequency[],
): void {
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
for (const pair of termReadingList) {
const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? [];
frequencyCache.set(key, exactEntries ?? termEntries);
}
}
export async function requestYomitanTermFrequencies( export async function requestYomitanTermFrequencies(
termReadingList: YomitanTermReadingPair[], termReadingList: YomitanTermReadingPair[],
deps: YomitanParserRuntimeDeps, deps: YomitanParserRuntimeDeps,
@@ -622,148 +765,83 @@ export async function requestYomitanTermFrequencies(
return buildCachedResult(); return buildCachedResult();
} }
if (metadata && metadata.dictionaries.length > 0) { const fetchedEntries = await fetchYomitanTermFrequencies(
const script = ` parserWindow,
(async () => { missingTermReadingList,
const invoke = (action, params) => metadata,
new Promise((resolve, reject) => { logger,
chrome.runtime.sendMessage({ action, params }, (response) => { );
if (chrome.runtime.lastError) { if (fetchedEntries === null) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
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(missingTermReadingList)},
dictionaries: ${JSON.stringify(metadata.dictionaries)}
});
})();
`;
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 key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? [];
frequencyCache.set(key, exactEntries ?? termEntries);
}
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(); return buildCachedResult();
} }
const script = ` cacheFrequencyEntriesForPairs(frequencyCache, missingTermReadingList, fetchedEntries);
(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 fallbackTermReadingList = normalizeTermReadingList(
const profileIndex = optionsFull.profileCurrent; missingTermReadingList
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? []; .filter((pair) => pair.reading !== null)
const dictionaryEntries = Array.isArray(dictionariesRaw) .map((pair) => {
? 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) {
const key = makeTermReadingCacheKey(pair.term, pair.reading); const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key); const cachedEntries = frequencyCache.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? []; if (cachedEntries && cachedEntries.length > 0) {
frequencyCache.set(key, exactEntries ?? termEntries); return null;
} }
const 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,
);
}
for (const pair of missingTermReadingList) {
if (pair.reading === null) {
continue;
}
const key = makeTermReadingCacheKey(pair.term, pair.reading);
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 allFetchedEntries = [...fetchedEntries, ...fallbackFetchedEntries];
const queriedTerms = new Set(
[...missingTermReadingList, ...fallbackTermReadingList].map((pair) => pair.term),
);
const cachedResult = buildCachedResult(); const cachedResult = buildCachedResult();
const unmatchedEntries = fetchedEntries.filter((entry) => !missingTerms.has(entry.term.trim())); const unmatchedEntries = allFetchedEntries.filter(
(entry) => !queriedTerms.has(entry.term.trim()),
);
return [...cachedResult, ...unmatchedEntries]; return [...cachedResult, ...unmatchedEntries];
} catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message);
return buildCachedResult();
}
} }
export async function syncYomitanDefaultAnkiServer( export async function syncYomitanDefaultAnkiServer(
@@ -846,7 +924,11 @@ export async function syncYomitanDefaultAnkiServer(
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`); logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
return true; return true;
} }
return false; const checkedWithoutUpdate =
typeof result === 'object' &&
result !== null &&
(result as { updated?: unknown }).updated === false;
return checkedWithoutUpdate;
} catch (err) { } catch (err) {
logger.error('Failed to sync Yomitan default profile Anki server:', (err as Error).message); logger.error('Failed to sync Yomitan default profile Anki server:', (err as Error).message);
return false; return false;

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
sanitizeHelpEnv,
sanitizeBackgroundEnv,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
} from './main-entry-runtime';
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
assert.equal(shouldHandleHelpOnlyAtEntry(['--start'], {}), false);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
});
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeHelpEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
const env = sanitizeBackgroundEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.SUBMINER_BACKGROUND_CHILD, '1');
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
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(['--start'], {}), false);
});

42
src/main-entry-runtime.ts Normal file
View File

@@ -0,0 +1,42 @@
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
const BACKGROUND_ARG = '--background';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
delete env.VK_INSTANCE_LAYERS;
}
}
function parseCliArgs(argv: string[]): CliArgs {
return parseArgs(argv);
}
export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!argv.includes(BACKGROUND_ARG)) return false;
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
return true;
}
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
const args = parseCliArgs(argv);
return args.help && !shouldStartApp(args);
}
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
if (!env.NODE_NO_WARNINGS) {
env.NODE_NO_WARNINGS = '1';
}
removeLsfgLayer(env);
return env;
}
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = sanitizeHelpEnv(baseEnv);
env[BACKGROUND_CHILD_ENV] = '1';
return env;
}

View File

@@ -1,26 +1,13 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { printHelp } from './cli/help';
import {
sanitizeBackgroundEnv,
sanitizeHelpEnv,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
} from './main-entry-runtime';
const BACKGROUND_ARG = '--background'; const DEFAULT_TEXTHOOKER_PORT = 5174;
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!argv.includes(BACKGROUND_ARG)) return false;
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
return true;
}
function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
env[BACKGROUND_CHILD_ENV] = '1';
if (!env.NODE_NO_WARNINGS) {
env.NODE_NO_WARNINGS = '1';
}
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
delete env.VK_INSTANCE_LAYERS;
}
return env;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) { if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const child = spawn(process.execPath, process.argv.slice(1), { const child = spawn(process.execPath, process.argv.slice(1), {
@@ -32,4 +19,14 @@ if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
process.exit(0); process.exit(0);
} }
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
delete process.env.VK_INSTANCE_LAYERS;
}
printHelp(DEFAULT_TEXTHOOKER_PORT);
process.exit(0);
}
require('./main.js'); require('./main.js');

View File

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

View File

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

View File

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

View File

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

View File

@@ -111,6 +111,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
listJellyfinLibraries: async () => [], listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [], listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [], listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {}, logInfo: () => {},
}, },
handleJellyfinPlayCommandMainDeps: { handleJellyfinPlayCommandMainDeps: {

View File

@@ -22,6 +22,14 @@ const BASE_METRICS: MpvSubtitleRenderMetrics = {
osdDimensions: null, osdDimensions: null,
}; };
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = []; const calls: string[] = [];
let started = false; let started = false;
@@ -236,3 +244,559 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.ok(calls.includes('warmup-yomitan')); assert.ok(calls.includes('warmup-yomitan'));
assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true')); assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true'));
}); });
test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annotations are disabled', async () => {
const calls: string[] = [];
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {}
on(): void {}
connect(): void {
this.connected = true;
}
}
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
mecabTokenizer = next as { id: string };
calls.push('set-mecab');
},
createMecabTokenizer: () => {
calls.push('create-mecab');
return { id: 'mecab' };
},
checkAvailability: async () => {
calls.push('check-mecab');
},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
await composed.startTokenizationWarmups();
assert.deepEqual(calls, []);
});
test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential tokenize calls', async () => {
let yomitanWarmupCalls = 0;
let prewarmJlptCalls = 0;
let prewarmFrequencyCalls = 0;
const tokenizeCalls: string[] = [];
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => {
tokenizeCalls.push(text);
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {
prewarmJlptCalls += 1;
},
ensureFrequencyDictionaryLookup: async () => {
prewarmFrequencyCalls += 1;
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1;
},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
await composed.tokenizeSubtitle('first');
await composed.tokenizeSubtitle('second');
assert.deepEqual(tokenizeCalls, ['first', 'second']);
assert.equal(yomitanWarmupCalls, 1);
assert.equal(prewarmJlptCalls, 0);
assert.equal(prewarmFrequencyCalls, 0);
});
test('composeMpvRuntimeHandlers does not block first tokenization on dictionary or MeCab warmup', async () => {
const jlptDeferred = createDeferred();
const frequencyDeferred = createDeferred();
const mecabDeferred = createDeferred();
let tokenizeResolved = false;
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => mecabDeferred.promise,
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
const tokenizePromise = composed.tokenizeSubtitle('first line').then(() => {
tokenizeResolved = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(tokenizeResolved, true);
jlptDeferred.resolve();
frequencyDeferred.resolve();
mecabDeferred.resolve();
await tokenizePromise;
await composed.startTokenizationWarmups();
});
test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-ready when dictionary warmup is still pending', async () => {
const jlptDeferred = createDeferred();
const frequencyDeferred = createDeferred();
const osdMessages: string[] = [];
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ onTokenizationReady?: (text: string) => void },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: (deps) =>
deps as unknown as { onTokenizationReady?: (text: string) => void },
tokenizeSubtitle: async (text, deps) => {
deps.onTokenizationReady?.(text);
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
showMpvOsd: (message) => {
osdMessages.push(message);
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
const warmupPromise = composed.startTokenizationWarmups();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, []);
await composed.tokenizeSubtitle('first line');
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
jlptDeferred.resolve();
frequencyDeferred.resolve();
await warmupPromise;
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ test('list handler no-ops when no list command is set', async () => {
listJellyfinLibraries: async () => [], listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [], listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [], listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {}, logInfo: () => {},
}); });
@@ -47,6 +48,7 @@ test('list handler logs libraries', async () => {
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }], listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
listJellyfinItems: async () => [], listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [], listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: (message) => logs.push(message), logInfo: (message) => logs.push(message),
}); });
@@ -67,14 +69,19 @@ test('list handler logs libraries', async () => {
test('list handler resolves items using default library id', async () => { test('list handler resolves items using default library id', async () => {
let usedLibraryId = ''; let usedLibraryId = '';
let usedRecursive: boolean | undefined;
let usedIncludeItemTypes: string | undefined;
const logs: string[] = []; const logs: string[] = [];
const handler = createHandleJellyfinListCommands({ const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [], listJellyfinLibraries: async () => [],
listJellyfinItems: async (_session, _clientInfo, params) => { listJellyfinItems: async (_session, _clientInfo, params) => {
usedLibraryId = params.libraryId; usedLibraryId = params.libraryId;
usedRecursive = params.recursive;
usedIncludeItemTypes = params.includeItemTypes;
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }]; return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
}, },
listJellyfinSubtitleTracks: async () => [], listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: (message) => logs.push(message), logInfo: (message) => logs.push(message),
}); });
@@ -86,6 +93,8 @@ test('list handler resolves items using default library id', async () => {
jellyfinLibraryId: '', jellyfinLibraryId: '',
jellyfinSearch: 'episode', jellyfinSearch: 'episode',
jellyfinLimit: 10, jellyfinLimit: 10,
jellyfinRecursive: false,
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
} as never, } as never,
session: baseSession, session: baseSession,
clientInfo: baseClientInfo, clientInfo: baseClientInfo,
@@ -96,6 +105,8 @@ test('list handler resolves items using default library id', async () => {
assert.equal(handled, true); assert.equal(handled, true);
assert.equal(usedLibraryId, 'default-lib'); assert.equal(usedLibraryId, 'default-lib');
assert.equal(usedRecursive, false);
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)'))); assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
}); });
@@ -104,6 +115,7 @@ test('list handler throws when items command has no library id', async () => {
listJellyfinLibraries: async () => [], listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [], listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [], listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {}, logInfo: () => {},
}); });
@@ -132,6 +144,7 @@ test('list handler logs subtitle urls only when requested', async () => {
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' }, { index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
{ index: 2, language: 'jpn' }, { index: 2, language: 'jpn' },
], ],
writeJellyfinPreviewAuth: () => {},
logInfo: (message) => logs.push(message), logInfo: (message) => logs.push(message),
}); });
@@ -157,6 +170,7 @@ test('list handler throws when subtitle command has no item id', async () => {
listJellyfinLibraries: async () => [], listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [], listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [], listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {}, logInfo: () => {},
}); });
@@ -174,3 +188,65 @@ test('list handler throws when subtitle command has no item id', async () => {
/Missing --jellyfin-item-id/, /Missing --jellyfin-item-id/,
); );
}); });
test('list handler writes preview auth payload to response path', async () => {
const writes: Array<{
path: string;
payload: { serverUrl: string; accessToken: string; userId: string };
}> = [];
const logs: string[] = [];
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logInfo: (message) => logs.push(message),
});
const handled = await handler({
args: {
jellyfinPreviewAuth: true,
jellyfinResponsePath: '/tmp/subminer-preview-auth.json',
} as never,
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: baseConfig,
});
assert.equal(handled, true);
assert.deepEqual(writes, [
{
path: '/tmp/subminer-preview-auth.json',
payload: {
serverUrl: baseSession.serverUrl,
accessToken: baseSession.accessToken,
userId: baseSession.userId,
},
},
]);
assert.deepEqual(logs, ['Jellyfin preview auth written.']);
});
test('list handler throws when preview auth command has no response path', async () => {
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
});
await assert.rejects(
handler({
args: {
jellyfinPreviewAuth: true,
} as never,
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: baseConfig,
}),
/Missing --jellyfin-response-path/,
);
});

View File

@@ -17,6 +17,12 @@ type JellyfinConfig = {
defaultLibraryId: string; defaultLibraryId: string;
}; };
type JellyfinPreviewAuthPayload = {
serverUrl: string;
accessToken: string;
userId: string;
};
export function createHandleJellyfinListCommands(deps: { export function createHandleJellyfinListCommands(deps: {
listJellyfinLibraries: ( listJellyfinLibraries: (
session: JellyfinSession, session: JellyfinSession,
@@ -25,7 +31,13 @@ export function createHandleJellyfinListCommands(deps: {
listJellyfinItems: ( listJellyfinItems: (
session: JellyfinSession, session: JellyfinSession,
clientInfo: JellyfinClientInfo, clientInfo: JellyfinClientInfo,
params: { libraryId: string; searchTerm?: string; limit: number }, params: {
libraryId: string;
searchTerm?: string;
limit: number;
recursive?: boolean;
includeItemTypes?: string;
},
) => Promise<Array<{ id: string; title: string; type: string }>>; ) => Promise<Array<{ id: string; title: string; type: string }>>;
listJellyfinSubtitleTracks: ( listJellyfinSubtitleTracks: (
session: JellyfinSession, session: JellyfinSession,
@@ -44,6 +56,7 @@ export function createHandleJellyfinListCommands(deps: {
deliveryUrl?: string | null; deliveryUrl?: string | null;
}> }>
>; >;
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
}) { }) {
return async (params: { return async (params: {
@@ -54,6 +67,20 @@ export function createHandleJellyfinListCommands(deps: {
}): Promise<boolean> => { }): Promise<boolean> => {
const { args, session, clientInfo, jellyfinConfig } = params; const { args, session, clientInfo, jellyfinConfig } = params;
if (args.jellyfinPreviewAuth) {
const responsePath = args.jellyfinResponsePath?.trim();
if (!responsePath) {
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
}
deps.writeJellyfinPreviewAuth(responsePath, {
serverUrl: session.serverUrl,
accessToken: session.accessToken,
userId: session.userId,
});
deps.logInfo('Jellyfin preview auth written.');
return true;
}
if (args.jellyfinLibraries) { if (args.jellyfinLibraries) {
const libraries = await deps.listJellyfinLibraries(session, clientInfo); const libraries = await deps.listJellyfinLibraries(session, clientInfo);
if (libraries.length === 0) { if (libraries.length === 0) {
@@ -79,6 +106,8 @@ export function createHandleJellyfinListCommands(deps: {
libraryId, libraryId,
searchTerm: args.jellyfinSearch, searchTerm: args.jellyfinSearch,
limit: args.jellyfinLimit ?? 100, limit: args.jellyfinLimit ?? 100,
recursive: args.jellyfinRecursive,
includeItemTypes: args.jellyfinIncludeItemTypes,
}); });
if (items.length === 0) { if (items.length === 0) {
deps.logInfo('No Jellyfin items found for the selected library/search.'); deps.logInfo('No Jellyfin items found for the selected library/search.');

View File

@@ -31,6 +31,10 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => {
test('jellyfin list commands main deps builder maps callbacks', async () => { test('jellyfin list commands main deps builder maps callbacks', async () => {
const calls: string[] = []; const calls: string[] = [];
const writes: Array<{
responsePath: string;
payload: { serverUrl: string; accessToken: string; userId: string };
}> = [];
const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({ const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({
listJellyfinLibraries: async () => { listJellyfinLibraries: async () => {
calls.push('libraries'); calls.push('libraries');
@@ -44,14 +48,32 @@ test('jellyfin list commands main deps builder maps callbacks', async () => {
calls.push('subtitles'); calls.push('subtitles');
return []; return [];
}, },
writeJellyfinPreviewAuth: (responsePath, payload) => {
writes.push({ responsePath, payload });
},
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
})(); })();
await deps.listJellyfinLibraries({} as never, {} as never); await deps.listJellyfinLibraries({} as never, {} as never);
await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 }); await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 });
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id'); await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id');
deps.writeJellyfinPreviewAuth('/tmp/jellyfin-preview.json', {
serverUrl: 'https://example.test',
accessToken: 'token',
userId: 'user-id',
});
deps.logInfo('done'); deps.logInfo('done');
assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']); assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']);
assert.deepEqual(writes, [
{
responsePath: '/tmp/jellyfin-preview.json',
payload: {
serverUrl: 'https://example.test',
accessToken: 'token',
userId: 'user-id',
},
},
]);
}); });
test('jellyfin play command main deps builder maps callbacks', async () => { test('jellyfin play command main deps builder maps callbacks', async () => {

View File

@@ -32,6 +32,8 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
deps.listJellyfinItems(session, clientInfo, params), deps.listJellyfinItems(session, clientInfo, params),
listJellyfinSubtitleTracks: (session, clientInfo, itemId) => listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId), deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
writeJellyfinPreviewAuth: (responsePath, payload) =>
deps.writeJellyfinPreviewAuth(responsePath, payload),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,7 +167,7 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']); assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
}); });
test('dictionary prewarm does not show OSD when notifications are disabled', async () => { test('dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', async () => {
const osdMessages: string[] = []; const osdMessages: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({ const prewarm = createPrewarmSubtitleDictionariesMainHandler({
@@ -181,7 +181,7 @@ test('dictionary prewarm does not show OSD when notifications are disabled', asy
await prewarm({ showLoadingOsd: true }); await prewarm({ showLoadingOsd: true });
assert.deepEqual(osdMessages, []); assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
}); });
test('dictionary prewarm clears loading OSD timer even if notifications are disabled before completion', async () => { test('dictionary prewarm clears loading OSD timer even if notifications are disabled before completion', async () => {

View File

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

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

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

View File

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

View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createJimakuModal } from './jimaku.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) {
tokens.add(entry);
}
},
remove: (...entries: string[]) => {
for (const entry of entries) {
tokens.delete(entry);
}
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
const classList = createClassList();
return {
textContent: '',
className: '',
style: {},
classList,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
test('successful Jimaku subtitle selection closes modal', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const modalCloseNotifications: Array<'runtime-options' | 'subsync' | 'jimaku' | 'kiku'> = [];
const electronAPI = {
jimakuDownloadFile: async () => ({ ok: true, path: '/tmp/subtitles/episode01.ass' }),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
modalCloseNotifications.push(modal);
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const overlayClassList = createClassList(['interactive']);
const jimakuModalClassList = createClassList();
const jimakuEntriesSectionClassList = createClassList(['hidden']);
const jimakuFilesSectionClassList = createClassList();
const jimakuBroadenButtonClassList = createClassList(['hidden']);
const state = createRendererState();
state.jimakuModalOpen = true;
state.currentEntryId = 42;
state.selectedFileIndex = 0;
state.jimakuFiles = [
{
name: 'episode01.ass',
url: 'https://jimaku.cc/files/episode01.ass',
size: 1000,
last_modified: '2026-03-01',
},
];
const ctx = {
dom: {
overlay: { classList: overlayClassList },
jimakuModal: {
classList: jimakuModalClassList,
setAttribute: () => {},
},
jimakuTitleInput: { value: '' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: jimakuEntriesSectionClassList },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: jimakuFilesSectionClassList },
jimakuFilesList: createListStub(),
jimakuBroadenButton: {
classList: jimakuBroadenButtonClassList,
addEventListener: () => {},
},
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
let prevented = false;
jimakuModal.handleJimakuKeydown({
key: 'Enter',
preventDefault: () => {
prevented = true;
},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(prevented, true);
assert.equal(state.jimakuModalOpen, false);
assert.equal(jimakuModalClassList.contains('hidden'), true);
assert.equal(overlayClassList.contains('interactive'), false);
assert.deepEqual(modalCloseNotifications, ['jimaku']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});

View File

@@ -234,6 +234,7 @@ export function createJimakuModal(
if (result.ok) { if (result.ok) {
setJimakuStatus(`Downloaded and loaded: ${result.path}`); setJimakuStatus(`Downloaded and loaded: ${result.path}`);
closeJimakuModal();
return; return;
} }

View File

@@ -0,0 +1,226 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubsyncModal } from './subsync.js';
type Listener = () => void;
function createClassList() {
const classes = new Set<string>();
return {
add: (...tokens: string[]) => {
for (const token of tokens) classes.add(token);
},
remove: (...tokens: string[]) => {
for (const token of tokens) classes.delete(token);
},
toggle: (token: string, force?: boolean) => {
if (force === undefined) {
if (classes.has(token)) classes.delete(token);
else classes.add(token);
return classes.has(token);
}
if (force) classes.add(token);
else classes.delete(token);
return force;
},
contains: (token: string) => classes.has(token),
};
}
function createEventTarget() {
const listeners = new Map<string, Listener[]>();
return {
addEventListener: (event: string, listener: Listener) => {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
},
dispatch: (event: string) => {
for (const listener of listeners.get(event) ?? []) {
listener();
}
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
const overlayClassList = createClassList();
const modalClassList = createClassList();
const statusClassList = createClassList();
const sourceLabelClassList = createClassList();
const runButtonEvents = createEventTarget();
const closeButtonEvents = createEventTarget();
const engineAlassEvents = createEventTarget();
const engineFfsubsyncEvents = createEventTarget();
const sourceOptions: Array<{ value: string; textContent: string }> = [];
const runButton = {
disabled: false,
addEventListener: runButtonEvents.addEventListener,
dispatch: runButtonEvents.dispatch,
};
const closeButton = {
addEventListener: closeButtonEvents.addEventListener,
dispatch: closeButtonEvents.dispatch,
};
const subsyncEngineAlass = {
checked: false,
addEventListener: engineAlassEvents.addEventListener,
dispatch: engineAlassEvents.dispatch,
};
const subsyncEngineFfsubsync = {
checked: false,
addEventListener: engineFfsubsyncEvents.addEventListener,
dispatch: engineFfsubsyncEvents.dispatch,
};
const sourceSelect = {
innerHTML: '',
value: '',
disabled: false,
appendChild: (option: { value: string; textContent: string }) => {
sourceOptions.push(option);
if (!sourceSelect.value) {
sourceSelect.value = option.value;
}
return option;
},
};
let notifyClosedCalls = 0;
let notifyOpenedCalls = 0;
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
runSubsyncManual,
notifyOverlayModalOpened: () => {
notifyOpenedCalls += 1;
},
notifyOverlayModalClosed: () => {
notifyClosedCalls += 1;
},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({ value: '', textContent: '' }),
},
});
const ctx = {
dom: {
overlay: { classList: overlayClassList },
subsyncModal: {
classList: modalClassList,
setAttribute: () => {},
},
subsyncCloseButton: closeButton,
subsyncEngineAlass,
subsyncEngineFfsubsync,
subsyncSourceLabel: { classList: sourceLabelClassList },
subsyncSourceSelect: sourceSelect,
subsyncRunButton: runButton,
subsyncStatus: {
textContent: '',
classList: statusClassList,
},
},
state: {
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubmitting: false,
isOverSubtitle: false,
},
};
const modal = createSubsyncModal(ctx as never, {
modalStateReader: {
isAnyModalOpen: () => false,
},
syncSettingsModalSubtitleSuppression: () => {},
});
return {
ctx,
modal,
runButton,
statusClassList,
getNotifyClosedCalls: () => notifyClosedCalls,
getNotifyOpenedCalls: () => notifyOpenedCalls,
restoreGlobals: () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
},
};
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
test('manual subsync failure closes during run, then reopens modal with error', async () => {
const deferred = createDeferred<{ ok: boolean; message: string }>();
const harness = createTestHarness(async () => deferred.promise);
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
});
harness.runButton.dispatch('click');
await Promise.resolve();
assert.equal(harness.ctx.state.subsyncModalOpen, false);
assert.equal(harness.getNotifyClosedCalls(), 1);
assert.equal(harness.getNotifyOpenedCalls(), 0);
deferred.resolve({
ok: false,
message: 'alass synchronization failed: code=1 stderr: invalid subtitle format',
});
await flushMicrotasks();
assert.equal(harness.ctx.state.subsyncModalOpen, true);
assert.equal(
harness.ctx.dom.subsyncStatus.textContent,
'alass synchronization failed: code=1 stderr: invalid subtitle format',
);
assert.equal(harness.statusClassList.contains('error'), true);
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
assert.equal(harness.getNotifyClosedCalls(), 1);
assert.equal(harness.getNotifyOpenedCalls(), 1);
} finally {
harness.restoreGlobals();
}
});

View File

@@ -71,6 +71,30 @@ export function createSubsyncModal(
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false'); ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
} }
function reopenSubsyncModalWithError(
sourceTracks: SubsyncManualPayload['sourceTracks'],
engine: 'alass' | 'ffsubsync',
sourceTrackId: number | null,
message: string,
): void {
openSubsyncModal({ sourceTracks });
if (engine === 'alass' && sourceTracks.length > 0) {
ctx.dom.subsyncEngineAlass.checked = true;
ctx.dom.subsyncEngineFfsubsync.checked = false;
if (Number.isFinite(sourceTrackId)) {
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
}
} else {
ctx.dom.subsyncEngineAlass.checked = false;
ctx.dom.subsyncEngineFfsubsync.checked = true;
}
updateSubsyncSourceVisibility();
setSubsyncStatus(message, true);
window.electronAPI.notifyOverlayModalOpened('subsync');
}
async function runSubsyncManualFromModal(): Promise<void> { async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return; if (ctx.state.subsyncSubmitting) return;
@@ -85,15 +109,25 @@ export function createSubsyncModal(
return; return;
} }
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
ctx.state.subsyncSubmitting = true; ctx.state.subsyncSubmitting = true;
ctx.dom.subsyncRunButton.disabled = true; ctx.dom.subsyncRunButton.disabled = true;
closeSubsyncModal(); closeSubsyncModal();
try { try {
await window.electronAPI.runSubsyncManual({ const result = await window.electronAPI.runSubsyncManual({
engine, engine,
sourceTrackId, sourceTrackId,
}); });
if (result.ok) return;
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
} catch (error) {
reopenSubsyncModalWithError(
sourceTracksSnapshot,
engine,
sourceTrackId,
`Subsync failed: ${(error as Error).message}`,
);
} finally { } finally {
ctx.state.subsyncSubmitting = false; ctx.state.subsyncSubmitting = false;
ctx.dom.subsyncRunButton.disabled = false; ctx.dom.subsyncRunButton.disabled = false;

View File

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

Some files were not shown because too many files have changed in this diff Show More