mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f0bd0ba355
|
|||
|
be4db24861
|
|||
|
83d21c4b6d
|
|||
|
e744fab067
|
|||
|
5167e3a494
|
|||
|
aff4e91bbb
|
|||
|
737101fe9e
|
|||
|
629fe97ef7
|
|||
|
fa97472bce
|
|||
|
83f13df627
|
|||
|
cde231b1ff
|
|||
|
7161fc3513
|
|||
|
9a91951656
|
|||
|
11e9c721c6
|
|||
|
3c66ea6b30
|
|||
|
79f37f3986
|
|||
|
f1b85b0751
|
|||
|
1ab5d00de0
|
|||
|
17a417e639
|
|||
|
68e5a7fef3
|
|||
| 7023a3263f |
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
id: TASK-81
|
||||||
|
title: 'Tokenization performance: disable Yomitan MeCab parser, gate local MeCab init, and add persistent MeCab process'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-02 07:44'
|
||||||
|
updated_date: '2026-03-02 20:44'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 9001
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Reduce subtitle annotation latency by:
|
||||||
|
|
||||||
|
- disabling Yomitan-side MeCab parser requests (`useMecabParser=false`);
|
||||||
|
- initializing local MeCab only when POS-dependent annotations are enabled (N+1 / JLPT / frequency);
|
||||||
|
- replacing per-line local MeCab process spawning with a persistent parser process that auto-shuts down after idle time and restarts on demand.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Yomitan parse requests disable MeCab parser path.
|
||||||
|
- [x] #2 MeCab warmup/init is skipped when all POS-dependent annotation toggles are off.
|
||||||
|
- [x] #3 Local MeCab tokenizer uses persistent process across subtitle lines.
|
||||||
|
- [x] #4 Persistent MeCab process auto-shuts down after idle timeout and restarts on next tokenize activity.
|
||||||
|
- [x] #5 Tests cover parser flag, warmup gating, and persistent MeCab lifecycle behavior.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented tokenizer latency optimizations:
|
||||||
|
|
||||||
|
- switched Yomitan parse requests to `useMecabParser: false`;
|
||||||
|
- added annotation-aware MeCab initialization gating in runtime warmup flow;
|
||||||
|
- added persistent local MeCab process (default idle shutdown: 30s) with queued requests, retry-on-process-end, idle auto-shutdown, and automatic restart on new work;
|
||||||
|
- added regression tests for Yomitan parse flag, MeCab warmup gating, and persistent/idle lifecycle behavior;
|
||||||
|
- fixed tokenization warmup gate so first-use warmup completion is sticky (`tokenizationWarmupCompleted`) and sequential `tokenizeSubtitle` calls no longer re-run Yomitan/dictionary warmup path;
|
||||||
|
- added regression coverage in `src/main/runtime/composers/mpv-runtime-composer.test.ts` for sequential tokenize calls (`warmup` side effects run once);
|
||||||
|
- post-review critical fix: treat Yomitan default-profile Anki server sync `no-change` as successful check, so `lastSyncedYomitanAnkiServer` is cached and expensive sync checks do not repeat on every subtitle line;
|
||||||
|
- added regression assertion in `src/core/services/tokenizer/yomitan-parser-runtime.test.ts` for `updated: false` path returning sync success;
|
||||||
|
- post-review performance fix: refactored POS enrichment to pre-index MeCab tokens by surface plus character-position overlap index, replacing repeated active-candidate filtering/full-scan behavior with direct overlap candidate lookup per token;
|
||||||
|
- added regression tests in `src/core/services/tokenizer/parser-enrichment-stage.test.ts` for repeated distant-token scan access and repeated active-candidate filter scans; both fail on scan-based behavior and pass with indexed lookup;
|
||||||
|
- post-review startup fix: moved JLPT/frequency dictionary initialization from synchronous FS APIs to async `fs/promises` path inspection/read and cooperative chunked entry processing to reduce main-thread stall risk during cold start;
|
||||||
|
- post-review first-line latency fix: decoupled tokenization warmup gating so first `tokenizeSubtitle` only waits on Yomitan extension readiness, while MeCab check + dictionary prewarm continue in parallel background warmups;
|
||||||
|
- validated with targeted tests and `tsc --noEmit`.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
id: TASK-82
|
||||||
|
title: 'Subtitle frequency highlighting: fix noisy Yomitan readings and restore known/N+1 color priority'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-02 20:10'
|
||||||
|
updated_date: '2026-03-02 01:44'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 9002
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Address frequency-highlighting regressions:
|
||||||
|
|
||||||
|
- tokens like `断じて` missed rank assignment when Yomitan merged-token reading was truncated/noisy;
|
||||||
|
- known/N+1 tokens were incorrectly colored by frequency color instead of known/N+1 color.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- known/N+1 color always wins;
|
||||||
|
- if token is frequent and within `topX`, frequency rank label can still appear on hover/metadata.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Frequency lookup succeeds for noisy/truncated merged-token readings via robust fallback behavior.
|
||||||
|
- [x] #2 Merged-token reading normalization restores missing kana suffixes where safe (`headword === surface` path).
|
||||||
|
- [x] #3 Known/N+1 tokens keep known/N+1 color classes; frequency color class does not override them.
|
||||||
|
- [x] #4 Frequency rank hover label remains available for in-range frequent tokens, including known/N+1.
|
||||||
|
- [x] #5 Regression tests added for tokenizer and renderer behavior.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented and validated:
|
||||||
|
|
||||||
|
- tokenizer now normalizes selected Yomitan merged-token readings by appending missing trailing kana suffixes when safe (`headword === surface`);
|
||||||
|
- frequency lookup now does lazy fallback: requests `{term, reading}` first, and only requests `{term, reading: null}` for misses;
|
||||||
|
- this removes eager `(term, null)` payload inflation on medium-frequency lines and reduces extension RPC payload/load;
|
||||||
|
- renderer restored known/N+1 color priority over frequency class coloring;
|
||||||
|
- frequency rank label display remains available for frequent known/N+1 tokens;
|
||||||
|
- added regression tests covering noisy-reading fallback, lazy fallback-query behavior, and renderer class/label precedence.
|
||||||
|
|
||||||
|
Related commits:
|
||||||
|
|
||||||
|
- `17a417e` (`fix(subtitle): improve frequency highlight reliability`)
|
||||||
|
- `79f37f3` (`fix(subtitle): prioritize known and n+1 colors over frequency`)
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-83
|
||||||
|
title: 'Jellyfin subtitle delay: shift to adjacent cue without seek jumps'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-02 00:06'
|
||||||
|
updated_date: '2026-03-02 00:06'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 9003
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Add keybinding-friendly special commands that shift `sub-delay` to align current subtitle start with next/previous cue start, without `sub-seek` probing (avoid playback jump).
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- add special commands for next/previous line alignment;
|
||||||
|
- compute delta from active subtitle cue timeline (external subtitle file/URL, including Jellyfin-delivered URLs);
|
||||||
|
- apply `add sub-delay <delta>` and show OSD value;
|
||||||
|
- keep existing proxy OSD behavior for direct `sub-delay` keybinding commands.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 New special commands exist for subtitle-delay shift to next/previous cue boundary.
|
||||||
|
- [x] #2 Shift logic parses active external subtitle source timings (SRT/VTT/ASS) and computes delta from current `sub-start`.
|
||||||
|
- [x] #3 Runtime applies delay shift without `sub-seek` and shows OSD feedback.
|
||||||
|
- [x] #4 Direct `sub-delay` proxy commands also show OSD current value.
|
||||||
|
- [x] #5 Tests added for cue parsing/shift behavior and IPC dispatch wiring.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented no-jump subtitle-delay alignment commands:
|
||||||
|
|
||||||
|
- added `__sub-delay-next-line` and `__sub-delay-prev-line` special commands;
|
||||||
|
- added `createShiftSubtitleDelayToAdjacentCueHandler` to parse cue start times from active external subtitle source and apply `add sub-delay` delta from current `sub-start`;
|
||||||
|
- wired command handling through IPC runtime deps into main runtime;
|
||||||
|
- retained/extended OSD proxy feedback for `sub-delay` keybindings;
|
||||||
|
- updated configuration docs and added regression tests for subtitle-delay shift and IPC command routing.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
3
bun.lock
3
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"name": "subminer",
|
"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=="],
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
15
docs/plausible.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||||
|
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||||
|
|
||||||
|
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => {
|
||||||
|
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
||||||
|
expect(docsThemeContents).toContain('const { init } = await import');
|
||||||
|
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
|
||||||
|
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'");
|
||||||
|
expect(docsThemeContents).toContain('outboundLinks: true');
|
||||||
|
expect(docsThemeContents).toContain('fileDownloads: true');
|
||||||
|
expect(docsThemeContents).toContain('formSubmissions: true');
|
||||||
|
});
|
||||||
@@ -46,6 +46,8 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| `ArrowDown` | Seek backward 60 seconds |
|
| `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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
111
src/core/services/app-lifecycle.test.ts
Normal file
111
src/core/services/app-lifecycle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(['你', '好', '猫']));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(':');
|
||||||
|
|||||||
@@ -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 () =>
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
28
src/core/services/jimaku-download-path.test.ts
Normal file
28
src/core/services/jimaku-download-path.test.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
51
src/core/services/jimaku-download-path.ts
Normal file
51
src/core/services/jimaku-download-path.ts
Normal 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}`;
|
||||||
|
}
|
||||||
75
src/core/services/jlpt-vocab.test.ts
Normal file
75
src/core/services/jlpt-vocab.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
|
|
||||||
|
test('createJlptVocabularyLookup loads JLPT bank entries and resolves known levels', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jlpt-dict-'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'term_meta_bank_5.json'),
|
||||||
|
JSON.stringify([
|
||||||
|
['猫', 1, { frequency: { displayValue: 1 } }],
|
||||||
|
['犬', 2, { frequency: { displayValue: 2 } }],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_1.json'), JSON.stringify([]));
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_2.json'), JSON.stringify([]));
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_3.json'), JSON.stringify([]));
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_4.json'), JSON.stringify([]));
|
||||||
|
|
||||||
|
const lookup = await createJlptVocabularyLookup({
|
||||||
|
searchPaths: [tempDir],
|
||||||
|
log: (message) => {
|
||||||
|
logs.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(lookup('猫'), 'N5');
|
||||||
|
assert.equal(lookup('犬'), 'N5');
|
||||||
|
assert.equal(lookup('鳥'), null);
|
||||||
|
assert.equal(
|
||||||
|
logs.some((entry) => entry.includes('JLPT dictionary loaded from')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createJlptVocabularyLookup does not require synchronous fs APIs', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jlpt-dict-'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'term_meta_bank_4.json'),
|
||||||
|
JSON.stringify([['見る', 1, { frequency: { displayValue: 3 } }]]),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_1.json'), JSON.stringify([]));
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_2.json'), JSON.stringify([]));
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_3.json'), JSON.stringify([]));
|
||||||
|
fs.writeFileSync(path.join(tempDir, 'term_meta_bank_5.json'), JSON.stringify([]));
|
||||||
|
|
||||||
|
const readFileSync = fs.readFileSync;
|
||||||
|
const statSync = fs.statSync;
|
||||||
|
const existsSync = fs.existsSync;
|
||||||
|
(fs as unknown as Record<string, unknown>).readFileSync = () => {
|
||||||
|
throw new Error('sync read disabled');
|
||||||
|
};
|
||||||
|
(fs as unknown as Record<string, unknown>).statSync = () => {
|
||||||
|
throw new Error('sync stat disabled');
|
||||||
|
};
|
||||||
|
(fs as unknown as Record<string, unknown>).existsSync = () => {
|
||||||
|
throw new Error('sync exists disabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lookup = await createJlptVocabularyLookup({
|
||||||
|
searchPaths: [tempDir],
|
||||||
|
log: () => undefined,
|
||||||
|
});
|
||||||
|
assert.equal(lookup('見る'), 'N4');
|
||||||
|
} finally {
|
||||||
|
(fs as unknown as Record<string, unknown>).readFileSync = readFileSync;
|
||||||
|
(fs as unknown as Record<string, unknown>).statSync = statSync;
|
||||||
|
(fs as unknown as Record<string, unknown>).existsSync = existsSync;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'path';
|
import * 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;
|
||||||
|
|||||||
@@ -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>> = [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||||
|
|
||||||
|
function createMpvClient(props: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name: string) => props[name],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shift subtitle delay to next cue using active external srt track', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const osd: string[] = [];
|
||||||
|
let loadCount = 0;
|
||||||
|
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||||
|
getMpvClient: () =>
|
||||||
|
createMpvClient({
|
||||||
|
'track-list': [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 2,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/subs.srt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sid: 2,
|
||||||
|
'sub-start': 3.0,
|
||||||
|
}),
|
||||||
|
loadSubtitleSourceText: async () => {
|
||||||
|
loadCount += 1;
|
||||||
|
return `1
|
||||||
|
00:00:01,000 --> 00:00:02,000
|
||||||
|
line-1
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:03,000 --> 00:00:04,000
|
||||||
|
line-2
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:05,000 --> 00:00:06,000
|
||||||
|
line-3`;
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
showMpvOsd: (text) => osd.push(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler('next');
|
||||||
|
await handler('next');
|
||||||
|
|
||||||
|
assert.equal(loadCount, 1);
|
||||||
|
assert.equal(commands.length, 2);
|
||||||
|
const delta = commands[0]?.[2];
|
||||||
|
assert.equal(commands[0]?.[0], 'add');
|
||||||
|
assert.equal(commands[0]?.[1], 'sub-delay');
|
||||||
|
assert.equal(typeof delta, 'number');
|
||||||
|
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
|
||||||
|
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shift subtitle delay to previous cue using active external ass track', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||||
|
getMpvClient: () =>
|
||||||
|
createMpvClient({
|
||||||
|
'track-list': [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 4,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/subs.ass',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sid: 4,
|
||||||
|
'sub-start': 2.0,
|
||||||
|
}),
|
||||||
|
loadSubtitleSourceText: async () => `[Events]
|
||||||
|
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
|
||||||
|
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
|
||||||
|
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler('previous');
|
||||||
|
|
||||||
|
const delta = commands[0]?.[2];
|
||||||
|
assert.equal(typeof delta, 'number');
|
||||||
|
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||||
|
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||||
|
getMpvClient: () =>
|
||||||
|
createMpvClient({
|
||||||
|
'track-list': [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 1,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/subs.vtt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sid: 1,
|
||||||
|
'sub-start': 5.0,
|
||||||
|
}),
|
||||||
|
loadSubtitleSourceText: async () => `WEBVTT
|
||||||
|
|
||||||
|
00:00:01.000 --> 00:00:02.000
|
||||||
|
line-1
|
||||||
|
|
||||||
|
00:00:03.000 --> 00:00:04.000
|
||||||
|
line-2
|
||||||
|
|
||||||
|
00:00:05.000 --> 00:00:06.000
|
||||||
|
line-3`,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
|
||||||
|
});
|
||||||
203
src/core/services/subtitle-delay-shift.ts
Normal file
203
src/core/services/subtitle-delay-shift.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
type SubtitleDelayShiftDirection = 'next' | 'previous';
|
||||||
|
|
||||||
|
type MpvClientLike = {
|
||||||
|
connected: boolean;
|
||||||
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MpvSubtitleTrackLike = {
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
external?: unknown;
|
||||||
|
'external-filename'?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubtitleCueCacheEntry = {
|
||||||
|
starts: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubtitleDelayShiftDeps = {
|
||||||
|
getMpvClient: () => MpvClientLike | null;
|
||||||
|
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => void;
|
||||||
|
showMpvOsd: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
if (Number.isInteger(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSrtOrVttStartTimes(content: string): number[] {
|
||||||
|
const starts: number[] = [];
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(
|
||||||
|
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
|
||||||
|
);
|
||||||
|
if (!match) continue;
|
||||||
|
const hours = Number(match[1] || 0);
|
||||||
|
const minutes = Number(match[2] || 0);
|
||||||
|
const seconds = Number(match[3] || 0);
|
||||||
|
const millis = Number(String(match[4]).padEnd(3, '0'));
|
||||||
|
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssStartTimes(content: string): number[] {
|
||||||
|
const starts: number[] = [];
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(
|
||||||
|
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
|
||||||
|
);
|
||||||
|
if (!match) continue;
|
||||||
|
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
|
||||||
|
if (secondsRaw === undefined) continue;
|
||||||
|
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
|
||||||
|
const hours = Number(hoursRaw);
|
||||||
|
const minutes = Number(minutesRaw);
|
||||||
|
const wholeSeconds = Number(wholeSecondsRaw);
|
||||||
|
const fraction = Number(`0.${fractionRaw}`);
|
||||||
|
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCueStarts(starts: number[]): number[] {
|
||||||
|
const sorted = starts
|
||||||
|
.filter((value) => Number.isFinite(value) && value >= 0)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (sorted.length === 0) return [];
|
||||||
|
|
||||||
|
const deduped: number[] = [sorted[0]!];
|
||||||
|
for (let i = 1; i < sorted.length; i += 1) {
|
||||||
|
const current = sorted[i]!;
|
||||||
|
const previous = deduped[deduped.length - 1]!;
|
||||||
|
if (Math.abs(current - previous) > 0.0005) {
|
||||||
|
deduped.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCueStarts(content: string, source: string): number[] {
|
||||||
|
const normalizedSource = source.toLowerCase().split('?')[0] || '';
|
||||||
|
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
|
||||||
|
const parseAssLike = () => parseAssStartTimes(content);
|
||||||
|
|
||||||
|
let starts: number[] = [];
|
||||||
|
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
|
||||||
|
starts = parseAssLike();
|
||||||
|
if (starts.length === 0) {
|
||||||
|
starts = parseSrtLike();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
starts = parseSrtLike();
|
||||||
|
if (starts.length === 0) {
|
||||||
|
starts = parseAssLike();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeCueStarts(starts);
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
|
||||||
|
const sid = asTrackId(sidRaw);
|
||||||
|
if (sid === null) {
|
||||||
|
throw new Error('No active subtitle track selected.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
throw new Error('Could not inspect subtitle track list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
|
||||||
|
if (!entry || typeof entry !== 'object') return false;
|
||||||
|
const track = entry as MpvSubtitleTrackLike;
|
||||||
|
return track.type === 'sub' && asTrackId(track.id) === sid;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeTrack) {
|
||||||
|
throw new Error('No active subtitle track found in mpv track list.');
|
||||||
|
}
|
||||||
|
if (activeTrack.external !== true) {
|
||||||
|
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source =
|
||||||
|
typeof activeTrack['external-filename'] === 'string'
|
||||||
|
? activeTrack['external-filename'].trim()
|
||||||
|
: '';
|
||||||
|
if (!source) {
|
||||||
|
throw new Error('Active subtitle track has no external subtitle source path.');
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAdjacentCueStart(
|
||||||
|
starts: number[],
|
||||||
|
currentStart: number,
|
||||||
|
direction: SubtitleDelayShiftDirection,
|
||||||
|
): number {
|
||||||
|
const epsilon = 0.0005;
|
||||||
|
if (direction === 'next') {
|
||||||
|
const target = starts.find((value) => value > currentStart + epsilon);
|
||||||
|
if (target === undefined) {
|
||||||
|
throw new Error('No next subtitle cue found for active subtitle source.');
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = starts.length - 1; index >= 0; index -= 1) {
|
||||||
|
const value = starts[index]!;
|
||||||
|
if (value < currentStart - epsilon) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('No previous subtitle cue found for active subtitle source.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
|
||||||
|
const cueCache = new Map<string, SubtitleCueCacheEntry>();
|
||||||
|
|
||||||
|
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (!client || !client.connected) {
|
||||||
|
throw new Error('MPV not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([
|
||||||
|
client.requestProperty('track-list'),
|
||||||
|
client.requestProperty('sid'),
|
||||||
|
client.requestProperty('sub-start'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentStart =
|
||||||
|
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
|
||||||
|
if (currentStart === null) {
|
||||||
|
throw new Error('Current subtitle start time is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
|
||||||
|
let cueStarts = cueCache.get(source)?.starts;
|
||||||
|
if (!cueStarts) {
|
||||||
|
const content = await deps.loadSubtitleSourceText(source);
|
||||||
|
cueStarts = parseCueStarts(content, source);
|
||||||
|
cueCache.set(source, { starts: cueStarts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||||
|
const delta = targetStart - currentStart;
|
||||||
|
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||||
|
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -297,6 +297,97 @@ test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in p
|
|||||||
assert.equal(result.tokens?.[0]?.frequencyRank, 77);
|
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(
|
||||||
'になれば',
|
'になれば',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
45
src/main-entry-runtime.test.ts
Normal file
45
src/main-entry-runtime.test.ts
Normal 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
42
src/main-entry-runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
144
src/main.ts
144
src/main.ts
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
114
src/mecab-tokenizer.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import * as childProcess from 'node:child_process';
|
||||||
|
import { PassThrough, Writable } from 'node:stream';
|
||||||
|
import { MecabTokenizer } from './mecab-tokenizer';
|
||||||
|
|
||||||
|
function createFakeMecabProcess(onKill: () => void): ReturnType<typeof childProcess.spawn> {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
const stdin = new Writable({
|
||||||
|
write(chunk, _encoding, callback) {
|
||||||
|
const text = String(chunk).replace(/\n+$/, '').trim();
|
||||||
|
if (!text) {
|
||||||
|
stdout.write('EOS\n');
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = `${text}\t名詞,一般,*,*,*,*,${text},${text},${text}\nEOS\n`;
|
||||||
|
stdout.write(payload);
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const process = new EventEmitter() as unknown as ReturnType<typeof childProcess.spawn> & {
|
||||||
|
stdin: Writable;
|
||||||
|
stdout: PassThrough;
|
||||||
|
stderr: PassThrough;
|
||||||
|
};
|
||||||
|
process.stdin = stdin;
|
||||||
|
process.stdout = stdout;
|
||||||
|
process.stderr = stderr;
|
||||||
|
process.kill = () => {
|
||||||
|
onKill();
|
||||||
|
process.emit('close', 0);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('MecabTokenizer reuses a persistent parser process across subtitle lines', async () => {
|
||||||
|
let spawnCalls = 0;
|
||||||
|
let killCalls = 0;
|
||||||
|
let timerId = 0;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
|
||||||
|
const tokenizer = new MecabTokenizer({
|
||||||
|
execSyncFn: (() => '/usr/bin/mecab') as unknown as typeof childProcess.execSync,
|
||||||
|
spawnFn: (() => {
|
||||||
|
spawnCalls += 1;
|
||||||
|
return createFakeMecabProcess(() => {
|
||||||
|
killCalls += 1;
|
||||||
|
});
|
||||||
|
}) as unknown as typeof childProcess.spawn,
|
||||||
|
setTimeoutFn: (callback) => {
|
||||||
|
timerId += 1;
|
||||||
|
timers.set(timerId, callback);
|
||||||
|
return timerId as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
clearTimeoutFn: (timeout) => {
|
||||||
|
timers.delete(timeout as unknown as number);
|
||||||
|
},
|
||||||
|
idleShutdownMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await tokenizer.checkAvailability(), true);
|
||||||
|
|
||||||
|
const first = await tokenizer.tokenize('猫');
|
||||||
|
const second = await tokenizer.tokenize('犬');
|
||||||
|
|
||||||
|
assert.equal(first?.[0]?.word, '猫');
|
||||||
|
assert.equal(second?.[0]?.word, '犬');
|
||||||
|
assert.equal(spawnCalls, 1);
|
||||||
|
assert.equal(killCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MecabTokenizer shuts down after idle timeout and restarts on new activity', async () => {
|
||||||
|
let spawnCalls = 0;
|
||||||
|
let killCalls = 0;
|
||||||
|
let timerId = 0;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
|
||||||
|
const tokenizer = new MecabTokenizer({
|
||||||
|
execSyncFn: (() => '/usr/bin/mecab') as unknown as typeof childProcess.execSync,
|
||||||
|
spawnFn: (() => {
|
||||||
|
spawnCalls += 1;
|
||||||
|
return createFakeMecabProcess(() => {
|
||||||
|
killCalls += 1;
|
||||||
|
});
|
||||||
|
}) as unknown as typeof childProcess.spawn,
|
||||||
|
setTimeoutFn: (callback) => {
|
||||||
|
timerId += 1;
|
||||||
|
timers.set(timerId, callback);
|
||||||
|
return timerId as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
clearTimeoutFn: (timeout) => {
|
||||||
|
timers.delete(timeout as unknown as number);
|
||||||
|
},
|
||||||
|
idleShutdownMs: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await tokenizer.checkAvailability(), true);
|
||||||
|
await tokenizer.tokenize('猫');
|
||||||
|
assert.equal(spawnCalls, 1);
|
||||||
|
|
||||||
|
const pendingTimer = [...timers.values()][0];
|
||||||
|
assert.ok(pendingTimer, 'expected idle shutdown timer');
|
||||||
|
pendingTimer?.();
|
||||||
|
assert.equal(killCalls, 1);
|
||||||
|
|
||||||
|
await tokenizer.tokenize('犬');
|
||||||
|
assert.equal(spawnCalls, 2);
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
149
src/renderer/modals/jimaku.test.ts
Normal file
149
src/renderer/modals/jimaku.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
226
src/renderer/modals/subsync.test.ts
Normal file
226
src/renderer/modals/subsync.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user