mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-03 18:22:42 -08:00
Compare commits
69 Commits
main
...
refactor-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
b17e3ea32a
|
|||
|
4b0a2ec486
|
|||
|
2c001e8017
|
|||
|
dd0ed3f849
|
|||
|
c8b65a01f6
|
|||
|
73e70b4395
|
|||
|
d0f29cfeae
|
|||
|
5e74209b61
|
|||
|
05805a3169
|
|||
|
cf9a444e08
|
|||
|
30e3e858f6
|
|||
|
3fe6b8c926
|
|||
|
0e64b630d0
|
|||
|
fb948c6feb
|
|||
|
a46f90d085
|
|||
|
33007b3f40
|
|||
|
e78e45b4e7
|
|||
|
a80d6dbea9
|
|||
|
cbff3f9ad9
|
|||
|
e4038127cb
|
|||
|
4309e0dec3
|
|||
|
55c577e911
|
|||
|
fd77f8f6a2
|
|||
|
dcc82c8052
|
|||
|
93336afa07
|
|||
|
a7d220e182
|
|||
|
498fd2d09a
|
|||
|
d2af09d941
|
|||
|
9c2618c4c7
|
|||
|
bf333c7c08
|
|||
|
dac9a3429a
|
|||
|
536db5ff85
|
|||
|
39288a62b6
|
|||
|
93e392910c
|
|||
|
185528aee6
|
|||
|
870acb45d5
|
|||
|
40787e8b71
|
|||
|
98fd2a731e
|
|||
|
de8c15fd56
|
|||
|
370274e78a
|
|||
|
9e0c5e478e
|
|||
|
3f1702b0f6
|
|||
|
66c24767fb
|
|||
|
f8e961d105
|
|||
|
34a0feae71
|
|||
|
db5e3f9e50
|
|||
|
30a76d7767
|
|||
|
1e645f961b
|
|||
|
3a1d746a2e
|
|||
|
17fa10ba36
|
|||
|
d6c4a85a3b
|
|||
|
19c7448f26
|
|||
|
b212986682
|
|||
|
d07b0aa957
|
|||
|
603af36a48
|
|||
|
5ef3396205
|
|||
|
721036342d
|
|||
|
c7c91077fd
|
|||
|
771ea5777f
|
|||
|
151752b17a
|
|||
|
62f53071ec
|
|||
|
337e3268f1
|
|||
|
fa0cb00f70
|
|||
|
a33a87bf8f
|
|||
|
3c2c8453be
|
|||
|
3c5ba3a3d3
|
|||
|
1ae46cd4ba
|
|||
|
1e2b43a7dc
|
|||
|
0de278f3ab
|
87
.github/workflows/release.yml
vendored
87
.github/workflows/release.yml
vendored
@@ -242,7 +242,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
tar -czf "release/subminer-assets.tar.gz" \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer.lua \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi
|
assets/themes/subminer.rasi
|
||||||
|
|
||||||
@@ -278,70 +278,45 @@ jobs:
|
|||||||
echo "$CHANGES" >> $GITHUB_OUTPUT
|
echo "$CHANGES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Create Release
|
||||||
env:
|
uses: softprops/action-gh-release@v2
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
with:
|
||||||
run: |
|
name: ${{ steps.version.outputs.VERSION }}
|
||||||
set -euo pipefail
|
body: |
|
||||||
|
## Changes
|
||||||
|
${{ steps.changelog.outputs.CHANGES }}
|
||||||
|
|
||||||
cat > release-body.md <<'EOF'
|
## Installation
|
||||||
## Changes
|
|
||||||
${{ steps.changelog.outputs.CHANGES }}
|
|
||||||
|
|
||||||
## Installation
|
### AppImage (Recommended)
|
||||||
|
1. Download the AppImage below
|
||||||
|
2. Make it executable: `chmod +x SubMiner.AppImage`
|
||||||
|
3. Run: `./SubMiner.AppImage`
|
||||||
|
|
||||||
### AppImage (Recommended)
|
### macOS
|
||||||
1. Download the AppImage below
|
1. Download `subminer-*.dmg`
|
||||||
2. Make it executable: `chmod +x SubMiner.AppImage`
|
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
||||||
3. Run: `./SubMiner.AppImage`
|
3. If needed, use the ZIP artifact as an alternative
|
||||||
|
|
||||||
### macOS
|
### Manual Installation
|
||||||
1. Download `subminer-*.dmg`
|
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
||||||
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
|
||||||
3. If needed, use the ZIP artifact as an alternative
|
|
||||||
|
|
||||||
### Manual Installation
|
### Optional Assets (config example + mpv plugin + rofi theme)
|
||||||
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
1. Download `subminer-assets.tar.gz`
|
||||||
|
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
||||||
|
3. Copy `plugin/subminer.lua` to `~/.config/mpv/scripts/`
|
||||||
|
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
||||||
|
5. Copy `assets/themes/subminer.rasi` to:
|
||||||
|
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
||||||
|
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
||||||
|
|
||||||
### Optional Assets (config example + mpv plugin + rofi theme)
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
1. Download `subminer-assets.tar.gz`
|
files: |
|
||||||
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
|
||||||
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
|
||||||
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
|
||||||
5. Copy `assets/themes/subminer.rasi` to:
|
|
||||||
- Linux: `~/.local/share/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`.
|
|
||||||
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,7 +18,6 @@ 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,7 +18,6 @@ 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.
|
||||||
@@ -44,7 +43,6 @@ 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.
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-84
|
|
||||||
title: 'Docs Plausible endpoint uses /api/event path'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-03 00:00'
|
|
||||||
updated_date: '2026-03-03 00:00'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 12000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
Fix VitePress docs Plausible tracker config to post to hosted worker API event endpoint instead of worker root URL.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 Docs theme Plausible `endpoint` points to `https://worker.subminer.moe/api/event`.
|
|
||||||
- [x] #2 Plausible docs test asserts `/api/event` endpoint path.
|
|
||||||
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Updated docs Plausible tracker endpoint to `https://worker.subminer.moe/api/event` and updated regression test expectation accordingly.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-84
|
|
||||||
title: Migrate AniSkip metadata+lookup orchestration to launcher/Electron
|
|
||||||
status: Done
|
|
||||||
assignee:
|
|
||||||
- Codex
|
|
||||||
created_date: '2026-03-03 08:31'
|
|
||||||
updated_date: '2026-03-03 08:35'
|
|
||||||
labels:
|
|
||||||
- enhancement
|
|
||||||
- aniskip
|
|
||||||
- launcher
|
|
||||||
- mpv-plugin
|
|
||||||
dependencies: []
|
|
||||||
references:
|
|
||||||
- launcher/aniskip-metadata.ts
|
|
||||||
- launcher/mpv.ts
|
|
||||||
- plugin/subminer/aniskip.lua
|
|
||||||
- plugin/subminer/options.lua
|
|
||||||
- plugin/subminer/state.lua
|
|
||||||
- plugin/subminer/lifecycle.lua
|
|
||||||
- plugin/subminer/messages.lua
|
|
||||||
- plugin/subminer.conf
|
|
||||||
- launcher/aniskip-metadata.test.ts
|
|
||||||
documentation:
|
|
||||||
- docs/mpv-plugin.md
|
|
||||||
- launcher/aniskip-metadata.ts
|
|
||||||
- plugin/subminer/aniskip.lua
|
|
||||||
- docs/architecture.md
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Move AniSkip MAL/title-to-MAL lookup and intro payload resolution from mpv Lua to launcher Electron flow, while keeping mpv-side intro skip UX and chapter/chapter prompt behavior in plugin. Launcher should infer/analyze file metadata, fetch AniSkip payload when launching files, and pass resolved skip window via script options; plugin should trust launcher payload and fall back only when absent.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [x] #1 Launcher infers AniSkip metadata for file targets using existing guessit/fallback logic and performs AniSkip MAL + payload resolution during mpv startup.
|
|
||||||
- [x] #2 Launcher injects script options containing resolved MAL id and intro window fields (or explicit lookup-failure status) into mpv startup.
|
|
||||||
- [x] #3 Lua plugin consumes launcher-provided AniSkip intro data and skips all network lookups when payload is present.
|
|
||||||
- [x] #4 Standalone mpv/plugin usage without launcher payload continues to function using existing async in-plugin lookup path.
|
|
||||||
- [x] #5 Docs and defaults are updated to document new script-option contract.
|
|
||||||
- [x] #6 Launcher tests cover payload generation contract and fallback behavior where metadata is unavailable.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
1) Add launcher-side AniSkip payload resolution helpers in launcher/aniskip-metadata.ts (MAL prefix lookup + AniSkip payload fetch + result normalization).
|
|
||||||
2) Wire launcher/mpv.ts + buildSubminerScriptOpts to pass resolved AniSkip fields/mode in --script-opts for file playback.
|
|
||||||
3) Update plugin/subminer/aniskip.lua plus options/state to consume injected payload: if intro_start/end present, apply immediately and skip network lookup; otherwise retain existing async behavior.
|
|
||||||
4) Ensure fallback for standalone mpv usage remains intact for no-launcher/manual refresh.
|
|
||||||
5) Add/update tests/docs/config references for new script-opt contract and edge cases.
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
Executed end-to-end migration so launcher resolves AniSkip title/MAL/payload before mpv start and injects it via --script-opts. Plugin now parses and consumes launcher payload (JSON/url/base64), applies OP intro from payload, tracks payload metadata in state, and keeps legacy async lookup path for non-launcher/absent payload playback. Added launcher config key aniskip_payload and updated launcher/aniskip-metadata tests for resolve/payload behavior and contract validation.
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
3
bun.lock
3
bun.lock
@@ -6,7 +6,6 @@
|
|||||||
"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",
|
||||||
@@ -189,8 +188,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
|
|||||||
@@ -88,7 +88,6 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -5,26 +5,8 @@ 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/api/event',
|
|
||||||
outboundLinks: true,
|
|
||||||
fileDownloads: true,
|
|
||||||
formSubmissions: true,
|
|
||||||
captureOnLocalhost: false,
|
|
||||||
});
|
|
||||||
plausibleTrackerInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMermaidModal() {
|
function closeMermaidModal() {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -206,12 +188,7 @@ export default {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(render);
|
||||||
initPlausibleTracker().catch((error) => {
|
|
||||||
console.error('Failed to initialize Plausible tracker:', error);
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
watch(() => route.path, render);
|
watch(() => route.path, render);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ Control the minimum log level for runtime output:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------- | ---------------------------------------- | --------------------------------------------------------- |
|
| ------- | ----------------------------------- | ------------------------------------------------ |
|
||||||
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
|
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
|
||||||
|
|
||||||
### Auto-Start Overlay
|
### Auto-Start Overlay
|
||||||
@@ -258,7 +258,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
@@ -322,7 +322,6 @@ 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,23 +364,21 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
|
|
||||||
**Default keybindings:**
|
**Default keybindings:**
|
||||||
|
|
||||||
| Key | Command | Description |
|
| Key | Command | Description |
|
||||||
| -------------------- | ---------------------------- | ------------------------------------- |
|
| ----------------- | ---------------------------- | ------------------------------------- |
|
||||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||||
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
|
||||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
|
||||||
|
|
||||||
**Custom keybindings example:**
|
**Custom keybindings example:**
|
||||||
|
|
||||||
@@ -405,11 +402,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. `__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.
|
**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.
|
||||||
|
|
||||||
**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`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
|
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), 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.
|
||||||
|
|
||||||
@@ -771,8 +768,7 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
"defaultMode": "auto",
|
"defaultMode": "auto",
|
||||||
"alass_path": "",
|
"alass_path": "",
|
||||||
"ffsubsync_path": "",
|
"ffsubsync_path": "",
|
||||||
"ffmpeg_path": "",
|
"ffmpeg_path": ""
|
||||||
"replace": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -783,7 +779,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
|
||||||
|
|
||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
@@ -889,7 +884,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 in background/tray mode.
|
- `subminer jellyfin -d` starts cast discovery 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,18 +60,12 @@ Launcher wrapper equivalent for interactive playback flow:
|
|||||||
subminer jellyfin -p
|
subminer jellyfin -p
|
||||||
```
|
```
|
||||||
|
|
||||||
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
Launcher wrapper for Jellyfin cast discovery mode (foreground app process):
|
||||||
|
|
||||||
```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:
|
||||||
@@ -86,17 +80,6 @@ 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
|
||||||
|
|||||||
@@ -79,18 +79,18 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| --------------------- | --------------------------------------------------- |
|
| ----------------------- | --------------------------------------------------- |
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
| `--start` | Explicitly start overlay after mpv launches |
|
| `--start` | Explicitly start overlay after mpv launches |
|
||||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||||
|
|
||||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
||||||
|
|
||||||
|
|||||||
@@ -120,28 +120,27 @@ aniskip_button_duration=3
|
|||||||
|
|
||||||
### Option Reference
|
### Option Reference
|
||||||
|
|
||||||
| Option | Default | Values | Description |
|
| Option | Default | Values | Description |
|
||||||
| ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
|
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
|
||||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||||
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||||
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||||
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
||||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||||
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
|
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
||||||
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
||||||
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
||||||
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
||||||
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
|
||||||
|
|
||||||
## Binary Auto-Detection
|
## Binary Auto-Detection
|
||||||
|
|
||||||
@@ -209,8 +208,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
- You explicitly call `script-message subminer-aniskip-refresh`.
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
- MAL/title resolution is cached for the current mpv session.
|
- MAL/title resolution is cached for the current mpv session.
|
||||||
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
|
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
||||||
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
|
||||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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 api endpoint', () => {
|
|
||||||
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/api/event'");
|
|
||||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
|
||||||
expect(docsThemeContents).toContain('fileDownloads: true');
|
|
||||||
expect(docsThemeContents).toContain('formSubmissions: true');
|
|
||||||
});
|
|
||||||
@@ -88,7 +88,6 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ 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,8 +55,7 @@ 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 (background tray app)
|
subminer jellyfin -d # Jellyfin cast-discovery mode (foreground 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
|
||||||
|
|||||||
@@ -4,38 +4,8 @@ import {
|
|||||||
inferAniSkipMetadataForFile,
|
inferAniSkipMetadataForFile,
|
||||||
buildSubminerScriptOpts,
|
buildSubminerScriptOpts,
|
||||||
parseAniSkipGuessitJson,
|
parseAniSkipGuessitJson,
|
||||||
resolveAniSkipMetadataForFile,
|
|
||||||
} from './aniskip-metadata';
|
} from './aniskip-metadata';
|
||||||
|
|
||||||
function makeMockResponse(payload: unknown): Response {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => payload,
|
|
||||||
} as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFetchInput(input: string | URL | Request): string {
|
|
||||||
if (typeof input === 'string') return input;
|
|
||||||
if (input instanceof URL) return input.toString();
|
|
||||||
return input.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withMockFetch(
|
|
||||||
handler: (input: string | URL | Request) => Promise<Response>,
|
|
||||||
fn: () => Promise<void>,
|
|
||||||
): Promise<void> {
|
|
||||||
const original = globalThis.fetch;
|
|
||||||
(globalThis as { fetch: typeof fetch }).fetch = (async (input: string | URL | Request) => {
|
|
||||||
return handler(input);
|
|
||||||
}) as typeof fetch;
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
} finally {
|
|
||||||
(globalThis as { fetch: typeof fetch }).fetch = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
||||||
const parsed = parseAniSkipGuessitJson(
|
const parsed = parseAniSkipGuessitJson(
|
||||||
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
||||||
@@ -46,10 +16,6 @@ test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
|||||||
season: 2,
|
season: 2,
|
||||||
episode: 7,
|
episode: 7,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
malId: null,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'lookup_failed',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,10 +34,6 @@ test('parseAniSkipGuessitJson prefers series over episode title', () => {
|
|||||||
season: 1,
|
season: 1,
|
||||||
episode: 10,
|
episode: 10,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
malId: null,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'lookup_failed',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,78 +60,16 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
|||||||
assert.equal(parsed.source, 'fallback');
|
assert.equal(parsed.source, 'fallback');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
|
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
||||||
await withMockFetch(
|
|
||||||
async (input) => {
|
|
||||||
const url = normalizeFetchInput(input);
|
|
||||||
if (url.includes('myanimelist.net/search/prefix.json')) {
|
|
||||||
return makeMockResponse({
|
|
||||||
categories: [
|
|
||||||
{
|
|
||||||
items: [
|
|
||||||
{ id: '9876', name: 'Wrong Match' },
|
|
||||||
{ id: '1234', name: 'My Show' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes('api.aniskip.com/v1/skip-times/1234/7')) {
|
|
||||||
return makeMockResponse({
|
|
||||||
found: true,
|
|
||||||
results: [{ skip_type: 'op', interval: { start_time: 12.5, end_time: 54.2 } }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected url: ${url}`);
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const resolved = await resolveAniSkipMetadataForFile('/media/Anime.My.Show.S01E07.mkv');
|
|
||||||
assert.equal(resolved.malId, 1234);
|
|
||||||
assert.equal(resolved.introStart, 12.5);
|
|
||||||
assert.equal(resolved.introEnd, 54.2);
|
|
||||||
assert.equal(resolved.lookupStatus, 'ready');
|
|
||||||
assert.equal(resolved.title, 'Anime My Show');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
|
|
||||||
await withMockFetch(
|
|
||||||
async () => makeMockResponse({ categories: [] }),
|
|
||||||
async () => {
|
|
||||||
const resolved = await resolveAniSkipMetadataForFile('/media/NopeShow.S01E03.mkv');
|
|
||||||
assert.equal(resolved.malId, null);
|
|
||||||
assert.equal(resolved.lookupStatus, 'missing_mal_id');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
|
||||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||||
title: "Frieren: Beyond Journey's End",
|
title: "Frieren: Beyond Journey's End",
|
||||||
season: 1,
|
season: 1,
|
||||||
episode: 5,
|
episode: 5,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
malId: 1234,
|
|
||||||
introStart: 30.5,
|
|
||||||
introEnd: 62,
|
|
||||||
lookupStatus: 'ready',
|
|
||||||
});
|
});
|
||||||
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
|
||||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
||||||
assert.match(opts, /subminer-aniskip_season=1/);
|
assert.match(opts, /subminer-aniskip_season=1/);
|
||||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||||
assert.match(opts, /subminer-aniskip_mal_id=1234/);
|
|
||||||
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
|
|
||||||
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
|
||||||
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
|
||||||
assert.ok(payloadMatch !== null);
|
|
||||||
const payload = JSON.parse(decodeURIComponent(payloadMatch[1]));
|
|
||||||
assert.equal(payload.found, true);
|
|
||||||
const first = payload.results?.[0];
|
|
||||||
assert.equal(first.skip_type, 'op');
|
|
||||||
assert.equal(first.interval.start_time, 30.5);
|
|
||||||
assert.equal(first.interval.end_time, 62);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,22 +2,11 @@ import path from 'node:path';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { commandExists } from './util.js';
|
import { commandExists } from './util.js';
|
||||||
|
|
||||||
export type AniSkipLookupStatus =
|
|
||||||
| 'ready'
|
|
||||||
| 'missing_mal_id'
|
|
||||||
| 'missing_episode'
|
|
||||||
| 'missing_payload'
|
|
||||||
| 'lookup_failed';
|
|
||||||
|
|
||||||
export interface AniSkipMetadata {
|
export interface AniSkipMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
source: 'guessit' | 'fallback';
|
source: 'guessit' | 'fallback';
|
||||||
malId: number | null;
|
|
||||||
introStart: number | null;
|
|
||||||
introEnd: number | null;
|
|
||||||
lookupStatus?: AniSkipLookupStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InferAniSkipDeps {
|
interface InferAniSkipDeps {
|
||||||
@@ -25,50 +14,6 @@ interface InferAniSkipDeps {
|
|||||||
runGuessit: (mediaPath: string) => string | null;
|
runGuessit: (mediaPath: string) => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MalSearchResult {
|
|
||||||
id?: unknown;
|
|
||||||
name?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MalSearchCategory {
|
|
||||||
items?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MalSearchResponse {
|
|
||||||
categories?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AniSkipIntervalPayload {
|
|
||||||
start_time?: unknown;
|
|
||||||
end_time?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AniSkipSkipItemPayload {
|
|
||||||
skip_type?: unknown;
|
|
||||||
interval?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AniSkipPayloadResponse {
|
|
||||||
found?: unknown;
|
|
||||||
results?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
|
||||||
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
|
||||||
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
|
|
||||||
const MAL_MATCH_STOPWORDS = new Set([
|
|
||||||
'the',
|
|
||||||
'this',
|
|
||||||
'that',
|
|
||||||
'world',
|
|
||||||
'animated',
|
|
||||||
'series',
|
|
||||||
'season',
|
|
||||||
'no',
|
|
||||||
'on',
|
|
||||||
'and',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function toPositiveInt(value: unknown): number | null {
|
function toPositiveInt(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
@@ -82,217 +27,6 @@ function toPositiveInt(value: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPositiveNumber(value: unknown): number | null {
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number.parseFloat(value);
|
|
||||||
if (Number.isFinite(parsed) && parsed > 0) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeForMatch(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeMatchWords(value: string): string[] {
|
|
||||||
const words = normalizeForMatch(value)
|
|
||||||
.split(' ')
|
|
||||||
.filter((word) => word.length >= 3);
|
|
||||||
return words.filter((word) => !MAL_MATCH_STOPWORDS.has(word));
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleOverlapScore(expectedTitle: string, candidateTitle: string): number {
|
|
||||||
const expected = normalizeForMatch(expectedTitle);
|
|
||||||
const candidate = normalizeForMatch(candidateTitle);
|
|
||||||
|
|
||||||
if (!expected || !candidate) return 0;
|
|
||||||
|
|
||||||
if (candidate.includes(expected)) return 120;
|
|
||||||
|
|
||||||
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
|
||||||
if (expectedTokens.length === 0) return 0;
|
|
||||||
|
|
||||||
const candidateSet = new Set(tokenizeMatchWords(candidateTitle));
|
|
||||||
let score = 0;
|
|
||||||
let matched = 0;
|
|
||||||
|
|
||||||
for (const token of expectedTokens) {
|
|
||||||
if (candidateSet.has(token)) {
|
|
||||||
score += 30;
|
|
||||||
matched += 1;
|
|
||||||
} else {
|
|
||||||
score -= 20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matched === 0) {
|
|
||||||
score -= 80;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverage = matched / expectedTokens.length;
|
|
||||||
if (expectedTokens.length >= 2) {
|
|
||||||
if (coverage >= 0.8) score += 30;
|
|
||||||
else if (coverage >= 0.6) score += 10;
|
|
||||||
else score -= 50;
|
|
||||||
} else if (coverage >= 1) {
|
|
||||||
score += 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAnySequelMarker(candidateTitle: string): boolean {
|
|
||||||
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
|
||||||
if (!normalized.trim()) return false;
|
|
||||||
|
|
||||||
const markers = [
|
|
||||||
'season 2',
|
|
||||||
'season 3',
|
|
||||||
'season 4',
|
|
||||||
'2nd season',
|
|
||||||
'3rd season',
|
|
||||||
'4th season',
|
|
||||||
'second season',
|
|
||||||
'third season',
|
|
||||||
'fourth season',
|
|
||||||
' ii ',
|
|
||||||
' iii ',
|
|
||||||
' iv ',
|
|
||||||
];
|
|
||||||
return markers.some((marker) => normalized.includes(marker));
|
|
||||||
}
|
|
||||||
|
|
||||||
function seasonSignalScore(requestedSeason: number | null, candidateTitle: string): number {
|
|
||||||
const season = toPositiveInt(requestedSeason);
|
|
||||||
if (!season || season < 1) return 0;
|
|
||||||
|
|
||||||
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
|
||||||
if (!normalized.trim()) return 0;
|
|
||||||
|
|
||||||
if (season === 1) {
|
|
||||||
return hasAnySequelMarker(candidateTitle) ? -60 : 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numericMarker = ` season ${season} `;
|
|
||||||
const ordinalMarker = ` ${season}th season `;
|
|
||||||
if (normalized.includes(numericMarker) || normalized.includes(ordinalMarker)) {
|
|
||||||
return 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
const romanAliases = {
|
|
||||||
2: [' ii ', ' second season ', ' 2nd season '],
|
|
||||||
3: [' iii ', ' third season ', ' 3rd season '],
|
|
||||||
4: [' iv ', ' fourth season ', ' 4th season '],
|
|
||||||
5: [' v ', ' fifth season ', ' 5th season '],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const aliases = romanAliases[season] ?? [];
|
|
||||||
return aliases.some((alias) => normalized.includes(alias)) ? 40 : hasAnySequelMarker(candidateTitle) ? -20 : 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMalSearchItems(payload: unknown): MalSearchResult[] {
|
|
||||||
const parsed = payload as MalSearchResponse;
|
|
||||||
const categories = Array.isArray(parsed?.categories) ? parsed.categories : null;
|
|
||||||
if (!categories) return [];
|
|
||||||
|
|
||||||
const items: MalSearchResult[] = [];
|
|
||||||
for (const category of categories) {
|
|
||||||
const typedCategory = category as MalSearchCategory;
|
|
||||||
const rawItems = Array.isArray(typedCategory?.items) ? typedCategory.items : [];
|
|
||||||
for (const rawItem of rawItems) {
|
|
||||||
const item = rawItem as Record<string, unknown>;
|
|
||||||
items.push({
|
|
||||||
id: item?.id,
|
|
||||||
name: item?.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEpisodePayload(value: unknown): number | null {
|
|
||||||
return toPositiveNumber(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
|
||||||
const parsed = payload as AniSkipPayloadResponse;
|
|
||||||
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
|
||||||
if (!results) return null;
|
|
||||||
|
|
||||||
for (const rawResult of results) {
|
|
||||||
const result = rawResult as AniSkipSkipItemPayload;
|
|
||||||
if (result.skip_type !== 'op' || typeof result.interval !== 'object' || result.interval === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const interval = result.interval as AniSkipIntervalPayload;
|
|
||||||
const start = normalizeEpisodePayload(interval?.start_time);
|
|
||||||
const end = normalizeEpisodePayload(interval?.end_time);
|
|
||||||
if (start !== null && end !== null && end > start) {
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': MAL_USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
try {
|
|
||||||
return (await response.json()) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveMalIdFromTitle(title: string, season: number | null): Promise<number | null> {
|
|
||||||
const lookup = season && season > 1 ? `${title} Season ${season}` : title;
|
|
||||||
const payload = await fetchJson<unknown>(`${MAL_PREFIX_API}${encodeURIComponent(lookup)}`);
|
|
||||||
const items = toMalSearchItems(payload);
|
|
||||||
if (!items.length) return null;
|
|
||||||
|
|
||||||
let bestScore = Number.NEGATIVE_INFINITY;
|
|
||||||
let bestMalId: number | null = null;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const id = toPositiveInt(item.id);
|
|
||||||
if (!id) continue;
|
|
||||||
const name = typeof item.name === 'string' ? item.name : '';
|
|
||||||
if (!name) continue;
|
|
||||||
|
|
||||||
const score = titleOverlapScore(title, name) + seasonSignalScore(season, name);
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
bestMalId = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAniSkipPayload(
|
|
||||||
malId: number,
|
|
||||||
episode: number,
|
|
||||||
): Promise<{ start: number; end: number } | null> {
|
|
||||||
const payload = await fetchJson<unknown>(`${ANISKIP_PAYLOAD_API}${malId}/${episode}?types=op&types=ed`);
|
|
||||||
const parsed = payload as AniSkipPayloadResponse;
|
|
||||||
if (!parsed || parsed.found !== true) return null;
|
|
||||||
return parseAniSkipPayload(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectEpisodeFromName(baseName: string): number | null {
|
function detectEpisodeFromName(baseName: string): number | null {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/[Ss]\d+[Ee](\d{1,3})/,
|
/[Ss]\d+[Ee](\d{1,3})/,
|
||||||
@@ -399,10 +133,6 @@ export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniS
|
|||||||
season,
|
season,
|
||||||
episode: episodeFromDirect ?? episodeFromList,
|
episode: episodeFromDirect ?? episodeFromList,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
malId: null,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'lookup_failed',
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -441,70 +171,9 @@ export function inferAniSkipMetadataForFile(
|
|||||||
season: detectSeasonFromNameOrDir(mediaPath),
|
season: detectSeasonFromNameOrDir(mediaPath),
|
||||||
episode: detectEpisodeFromName(baseName),
|
episode: detectEpisodeFromName(baseName),
|
||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
malId: null,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'lookup_failed',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<AniSkipMetadata> {
|
|
||||||
const inferred = inferAniSkipMetadataForFile(mediaPath);
|
|
||||||
if (!inferred.title) {
|
|
||||||
return { ...inferred, lookupStatus: 'lookup_failed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const malId = await resolveMalIdFromTitle(inferred.title, inferred.season);
|
|
||||||
if (!malId) {
|
|
||||||
return {
|
|
||||||
...inferred,
|
|
||||||
malId: null,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'missing_mal_id',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inferred.episode) {
|
|
||||||
return {
|
|
||||||
...inferred,
|
|
||||||
malId,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'missing_episode',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await fetchAniSkipPayload(malId, inferred.episode);
|
|
||||||
if (!payload) {
|
|
||||||
return {
|
|
||||||
...inferred,
|
|
||||||
malId,
|
|
||||||
introStart: null,
|
|
||||||
introEnd: null,
|
|
||||||
lookupStatus: 'missing_payload',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...inferred,
|
|
||||||
malId,
|
|
||||||
introStart: payload.start,
|
|
||||||
introEnd: payload.end,
|
|
||||||
lookupStatus: 'ready',
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
...inferred,
|
|
||||||
malId: inferred.malId,
|
|
||||||
introStart: inferred.introStart,
|
|
||||||
introEnd: inferred.introEnd,
|
|
||||||
lookupStatus: 'lookup_failed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeScriptOptValue(value: string): string {
|
function sanitizeScriptOptValue(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/,/g, ' ')
|
.replace(/,/g, ' ')
|
||||||
@@ -513,28 +182,6 @@ function sanitizeScriptOptValue(value: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
|
|
||||||
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
found: true,
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
skip_type: 'op',
|
|
||||||
interval: {
|
|
||||||
start_time: aniSkipMetadata.introStart,
|
|
||||||
end_time: aniSkipMetadata.introEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return encodeURIComponent(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSubminerScriptOpts(
|
export function buildSubminerScriptOpts(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
@@ -553,21 +200,5 @@ export function buildSubminerScriptOpts(
|
|||||||
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
||||||
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
||||||
}
|
}
|
||||||
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
|
|
||||||
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
|
|
||||||
}
|
|
||||||
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
|
|
||||||
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
|
|
||||||
}
|
|
||||||
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
|
|
||||||
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
|
|
||||||
}
|
|
||||||
if (aniSkipMetadata?.lookupStatus) {
|
|
||||||
parts.push(`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`);
|
|
||||||
}
|
|
||||||
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
|
|
||||||
if (aniskipPayload) {
|
|
||||||
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
|
|
||||||
}
|
|
||||||
return parts.join(',');
|
return parts.join(',');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinDiscovery) {
|
if (args.jellyfinDiscovery) {
|
||||||
const forwarded = ['--background', '--jellyfin-remote-announce'];
|
const forwarded = ['--start'];
|
||||||
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,10 +143,14 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||||
|
|
||||||
if (shouldPauseUntilOverlayReady) {
|
if (shouldPauseUntilOverlayReady) {
|
||||||
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'Configured to pause mpv until overlay and tokenization are ready',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await startMpv(
|
startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
args,
|
args,
|
||||||
@@ -194,7 +198,11 @@ 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('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (ready) {
|
} else if (ready) {
|
||||||
log(
|
log(
|
||||||
|
|||||||
@@ -52,10 +52,7 @@ export function parsePluginRuntimeConfigContent(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (key === 'auto_start_visible_overlay') {
|
if (key === 'auto_start_visible_overlay') {
|
||||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value);
|
||||||
'auto_start_visible_overlay',
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (key === 'auto_start_pause_until_ready') {
|
if (key === 'auto_start_pause_until_ready') {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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,
|
||||||
@@ -9,8 +8,8 @@ import type {
|
|||||||
JellyfinItemEntry,
|
JellyfinItemEntry,
|
||||||
JellyfinGroupEntry,
|
JellyfinGroupEntry,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail } from './log.js';
|
||||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
import { commandExists, resolvePathMaybe } from './util.js';
|
||||||
import {
|
import {
|
||||||
pickLibrary,
|
pickLibrary,
|
||||||
pickItem,
|
pickItem,
|
||||||
@@ -19,17 +18,12 @@ 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(/\/+$/, '');
|
||||||
}
|
}
|
||||||
@@ -120,606 +114,6 @@ 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,
|
||||||
@@ -973,37 +367,18 @@ 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 hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||||
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;
|
||||||
@@ -1018,7 +393,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,19 +5,6 @@ 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;
|
||||||
@@ -162,7 +149,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
test('jellyfin discovery routes to app --start 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');
|
||||||
@@ -182,37 +169,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
|||||||
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(
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n');
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,182 +238,3 @@ 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 { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv';
|
import { 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,18 +19,6 @@ 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 {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
@@ -419,7 +419,7 @@ export async function loadSubtitleIntoMpv(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMpv(
|
export function startMpv(
|
||||||
target: string,
|
target: string,
|
||||||
targetKind: 'file' | 'url',
|
targetKind: 'file' | 'url',
|
||||||
args: Args,
|
args: Args,
|
||||||
@@ -479,8 +479,7 @@ export async function startMpv(
|
|||||||
if (options?.startPaused) {
|
if (options?.startPaused) {
|
||||||
mpvArgs.push('--pause=yes');
|
mpvArgs.push('--pause=yes');
|
||||||
}
|
}
|
||||||
const aniSkipMetadata =
|
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
||||||
targetKind === 'file' ? await resolveAniSkipMetadataForFile(target) : null;
|
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
@@ -659,28 +658,6 @@ 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.3",
|
"version": "0.2.0",
|
||||||
"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/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: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: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: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: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,7 +58,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ aniskip_mal_id=
|
|||||||
# Force episode number (optional). Leave blank for filename/title detection.
|
# Force episode number (optional). Leave blank for filename/title detection.
|
||||||
aniskip_episode=
|
aniskip_episode=
|
||||||
|
|
||||||
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
|
|
||||||
aniskip_payload=
|
|
||||||
|
|
||||||
# Show intro skip OSD button while inside OP range.
|
# Show intro skip OSD button while inside OP range.
|
||||||
aniskip_show_button=yes
|
aniskip_show_button=yes
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ function M.create(ctx)
|
|||||||
local mal_lookup_cache = {}
|
local mal_lookup_cache = {}
|
||||||
local payload_cache = {}
|
local payload_cache = {}
|
||||||
local title_context_cache = {}
|
local title_context_cache = {}
|
||||||
local base64_reverse = {}
|
|
||||||
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
||||||
|
|
||||||
for i = 1, #base64_chars do
|
|
||||||
base64_reverse[base64_chars:sub(i, i)] = i - 1
|
|
||||||
end
|
|
||||||
|
|
||||||
local function url_encode(text)
|
local function url_encode(text)
|
||||||
if type(text) ~= "string" then
|
if type(text) ~= "string" then
|
||||||
@@ -31,109 +25,6 @@ function M.create(ctx)
|
|||||||
return encoded:gsub(" ", "%%20")
|
return encoded:gsub(" ", "%%20")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parse_json_payload(text)
|
|
||||||
if type(text) ~= "string" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local parsed, parse_error = utils.parse_json(text)
|
|
||||||
if type(parsed) == "table" then
|
|
||||||
return parsed
|
|
||||||
end
|
|
||||||
return nil, parse_error
|
|
||||||
end
|
|
||||||
|
|
||||||
local function decode_base64(input)
|
|
||||||
if type(input) ~= "string" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
|
|
||||||
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
|
||||||
if cleaned == "" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if #cleaned % 4 == 1 then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if #cleaned % 4 ~= 0 then
|
|
||||||
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
|
|
||||||
end
|
|
||||||
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local out = {}
|
|
||||||
local out_len = 0
|
|
||||||
for index = 1, #cleaned, 4 do
|
|
||||||
local c1 = cleaned:sub(index, index)
|
|
||||||
local c2 = cleaned:sub(index + 1, index + 1)
|
|
||||||
local c3 = cleaned:sub(index + 2, index + 2)
|
|
||||||
local c4 = cleaned:sub(index + 3, index + 3)
|
|
||||||
local v1 = base64_reverse[c1]
|
|
||||||
local v2 = base64_reverse[c2]
|
|
||||||
if not v1 or not v2 then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local v3 = c3 == "=" and 0 or base64_reverse[c3]
|
|
||||||
local v4 = c4 == "=" and 0 or base64_reverse[c4]
|
|
||||||
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
|
|
||||||
local b1 = math.floor(n / 65536)
|
|
||||||
local remaining = n % 65536
|
|
||||||
local b2 = math.floor(remaining / 256)
|
|
||||||
local b3 = remaining % 256
|
|
||||||
out_len = out_len + 1
|
|
||||||
out[out_len] = string.char(b1)
|
|
||||||
if c3 ~= "=" then
|
|
||||||
out_len = out_len + 1
|
|
||||||
out[out_len] = string.char(b2)
|
|
||||||
end
|
|
||||||
if c4 ~= "=" then
|
|
||||||
out_len = out_len + 1
|
|
||||||
out[out_len] = string.char(b3)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return table.concat(out)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function resolve_launcher_payload()
|
|
||||||
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
|
|
||||||
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
|
|
||||||
if trimmed == "" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local parsed, parse_error = parse_json_payload(trimmed)
|
|
||||||
if type(parsed) == "table" then
|
|
||||||
return parsed
|
|
||||||
end
|
|
||||||
|
|
||||||
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
|
|
||||||
local value = tonumber(hex, 16)
|
|
||||||
if value then
|
|
||||||
return string.char(value)
|
|
||||||
end
|
|
||||||
return "%"
|
|
||||||
end)
|
|
||||||
if url_decoded ~= trimmed then
|
|
||||||
parsed, parse_error = parse_json_payload(url_decoded)
|
|
||||||
if type(parsed) == "table" then
|
|
||||||
return parsed
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local b64_decoded = decode_base64(trimmed)
|
|
||||||
if type(b64_decoded) == "string" and b64_decoded ~= "" then
|
|
||||||
parsed, parse_error = parse_json_payload(b64_decoded)
|
|
||||||
if type(parsed) == "table" then
|
|
||||||
return parsed
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function run_json_curl_async(url, callback)
|
local function run_json_curl_async(url, callback)
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
@@ -405,8 +296,6 @@ function M.create(ctx)
|
|||||||
state.aniskip.episode = nil
|
state.aniskip.episode = nil
|
||||||
state.aniskip.intro_start = nil
|
state.aniskip.intro_start = nil
|
||||||
state.aniskip.intro_end = nil
|
state.aniskip.intro_end = nil
|
||||||
state.aniskip.payload = nil
|
|
||||||
state.aniskip.payload_source = nil
|
|
||||||
remove_aniskip_chapters()
|
remove_aniskip_chapters()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -477,17 +366,7 @@ function M.create(ctx)
|
|||||||
state.aniskip.intro_end = intro_end
|
state.aniskip.intro_end = intro_end
|
||||||
state.aniskip.prompt_shown = false
|
state.aniskip.prompt_shown = false
|
||||||
set_intro_chapters(intro_start, intro_end)
|
set_intro_chapters(intro_start, intro_end)
|
||||||
subminer_log(
|
subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode))
|
||||||
"info",
|
|
||||||
"aniskip",
|
|
||||||
string.format(
|
|
||||||
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
|
|
||||||
intro_start,
|
|
||||||
intro_end,
|
|
||||||
tostring(mal_id or "-"),
|
|
||||||
tostring(episode or "-")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -495,10 +374,6 @@ function M.create(ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function has_launcher_payload()
|
|
||||||
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function is_launcher_context()
|
local function is_launcher_context()
|
||||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
if forced_title ~= "" then
|
if forced_title ~= "" then
|
||||||
@@ -516,9 +391,6 @@ function M.create(ctx)
|
|||||||
if forced_season and forced_season > 0 then
|
if forced_season and forced_season > 0 then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if has_launcher_payload() then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -628,18 +500,6 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
|
|
||||||
if not payload then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
state.aniskip.payload = payload
|
|
||||||
state.aniskip.payload_source = "launcher"
|
|
||||||
state.aniskip.mal_id = mal_id
|
|
||||||
state.aniskip.title = title
|
|
||||||
state.aniskip.episode = episode
|
|
||||||
return apply_aniskip_payload(mal_id, title, episode, payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function fetch_aniskip_for_current_media(trigger_source)
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
if not opts.aniskip_enabled then
|
if not opts.aniskip_enabled then
|
||||||
@@ -658,28 +518,6 @@ function M.create(ctx)
|
|||||||
reset_aniskip_fields()
|
reset_aniskip_fields()
|
||||||
local title, episode, season = resolve_title_and_episode()
|
local title, episode, season = resolve_title_and_episode()
|
||||||
local lookup_titles = resolve_lookup_titles(title)
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
local launcher_payload = resolve_launcher_payload()
|
|
||||||
if launcher_payload then
|
|
||||||
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
|
|
||||||
if not launcher_mal_id then
|
|
||||||
launcher_mal_id = nil
|
|
||||||
end
|
|
||||||
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
|
|
||||||
subminer_log(
|
|
||||||
"info",
|
|
||||||
"aniskip",
|
|
||||||
string.format(
|
|
||||||
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
|
|
||||||
tostring(title or ""),
|
|
||||||
tostring(season or "-"),
|
|
||||||
tostring(episode or "-")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
@@ -720,8 +558,6 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
state.aniskip.payload = payload
|
|
||||||
state.aniskip.payload_source = "remote"
|
|
||||||
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function M.load(options_lib, default_socket_path)
|
|||||||
aniskip_season = "",
|
aniskip_season = "",
|
||||||
aniskip_mal_id = "",
|
aniskip_mal_id = "",
|
||||||
aniskip_episode = "",
|
aniskip_episode = "",
|
||||||
aniskip_payload = "",
|
|
||||||
aniskip_show_button = true,
|
aniskip_show_button = true,
|
||||||
aniskip_button_text = "You can skip by pressing %s",
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
aniskip_button_key = "y-k",
|
aniskip_button_key = "y-k",
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ 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
|
||||||
@@ -72,50 +70,28 @@ function M.create(ctx)
|
|||||||
state.auto_play_ready_timeout = nil
|
state.auto_play_ready_timeout = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function clear_auto_play_ready_osd_timer()
|
local function disarm_auto_play_ready_gate()
|
||||||
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({ resume_playback = false })
|
disarm_auto_play_ready_gate()
|
||||||
mp.set_property_native("pause", false)
|
mp.set_property_native("pause", false)
|
||||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
show_osd("Subtitle annotations loaded")
|
||||||
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(AUTO_PLAY_READY_LOADING_OSD)
|
show_osd("Loading subtitle annotations...")
|
||||||
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
|
||||||
@@ -275,23 +251,6 @@ 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")
|
||||||
@@ -328,7 +287,7 @@ function M.create(ctx)
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if attempt == 1 and not state.auto_play_ready_gate_armed then
|
if attempt == 1 then
|
||||||
show_osd("Starting...")
|
show_osd("Starting...")
|
||||||
end
|
end
|
||||||
state.overlay_running = true
|
state.overlay_running = true
|
||||||
|
|||||||
@@ -24,14 +24,11 @@ function M.new()
|
|||||||
episode = nil,
|
episode = nil,
|
||||||
intro_start = nil,
|
intro_start = nil,
|
||||||
intro_end = nil,
|
intro_end = nil,
|
||||||
payload = nil,
|
|
||||||
payload_source = nil,
|
|
||||||
found = false,
|
found = false,
|
||||||
prompt_shown = false,
|
prompt_shown = false,
|
||||||
},
|
},
|
||||||
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,7 +9,6 @@ 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()
|
||||||
@@ -91,32 +90,10 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function mp.add_timeout(seconds, callback)
|
function mp.add_timeout(_seconds, callback)
|
||||||
local timeout = {
|
if callback then
|
||||||
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)
|
||||||
@@ -304,26 +281,6 @@ 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
|
||||||
@@ -395,16 +352,6 @@ 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
|
||||||
@@ -546,64 +493,12 @@ 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 = "",
|
||||||
@@ -633,54 +528,13 @@ 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 tokenization..."),
|
has_osd_message(recorded.osd, "SubMiner: Loading subtitle annotations..."),
|
||||||
"pause-until-ready auto-start should show loading OSD message"
|
"pause-until-ready auto-start should show loading OSD message"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not has_osd_message(recorded.osd, "SubMiner: Starting..."),
|
has_osd_message(recorded.osd, "SubMiner: Subtitle annotations loaded"),
|
||||||
"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,33 +316,3 @@ 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,55 +58,6 @@ 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;
|
||||||
@@ -778,12 +729,8 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
||||||
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
|
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : '';
|
||||||
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
|
const filenameWithExt = videoFilename || fallbackFilename;
|
||||||
const filenameWithExt =
|
|
||||||
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
|
|
||||||
? mediaTitle || videoFilename
|
|
||||||
: videoFilename || mediaTitle) || fallbackFilename;
|
|
||||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
const currentTimePos =
|
const currentTimePos =
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args';
|
||||||
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([
|
||||||
@@ -47,30 +42,6 @@ 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);
|
||||||
@@ -147,16 +118,6 @@ 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,7 +33,6 @@ 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;
|
||||||
@@ -50,11 +49,8 @@ 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';
|
||||||
}
|
}
|
||||||
@@ -97,7 +93,6 @@ 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,
|
||||||
@@ -152,7 +147,6 @@ 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;
|
||||||
@@ -235,27 +229,6 @@ 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;
|
||||||
@@ -268,12 +241,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +282,6 @@ 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
|
||||||
@@ -384,7 +350,6 @@ 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,10 +47,7 @@ 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(
|
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Inter, Noto Sans, Helvetica Neue, sans-serif');
|
||||||
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, '');
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
alass_path: '',
|
alass_path: '',
|
||||||
ffsubsync_path: '',
|
ffsubsync_path: '',
|
||||||
ffmpeg_path: '',
|
ffmpeg_path: '',
|
||||||
replace: true,
|
|
||||||
},
|
},
|
||||||
startupWarmups: {
|
startupWarmups: {
|
||||||
lowPowerMode: false,
|
lowPowerMode: false,
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subsync.defaultMode,
|
defaultValue: defaultConfig.subsync.defaultMode,
|
||||||
description: 'Subsync default mode.',
|
description: 'Subsync default mode.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'subsync.replace',
|
|
||||||
kind: 'boolean',
|
|
||||||
defaultValue: defaultConfig.subsync.replace,
|
|
||||||
description: 'Replace the active subtitle file when sync completes.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'startupWarmups.lowPowerMode',
|
path: 'startupWarmups.lowPowerMode',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ 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']> = [
|
||||||
@@ -58,11 +56,6 @@ 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'] },
|
||||||
|
|||||||
@@ -173,12 +173,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||||
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
||||||
const replace = asBoolean(src.subsync.replace);
|
|
||||||
if (replace !== undefined) {
|
|
||||||
resolved.subsync.replace = replace;
|
|
||||||
} else if (src.subsync.replace !== undefined) {
|
|
||||||
warn('subsync.replace', src.subsync.replace, resolved.subsync.replace, 'Expected boolean.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.subtitlePosition)) {
|
if (isObject(src.subtitlePosition)) {
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ 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 = resolved.subtitleStyle.autoPauseVideoOnHover;
|
const fallbackSubtitleStyleAutoPauseVideoOnHover =
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
@@ -160,7 +161,8 @@ 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 !== undefined
|
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !==
|
||||||
|
undefined
|
||||||
) {
|
) {
|
||||||
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
|
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
|
||||||
warn(
|
warn(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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');
|
||||||
|
|
||||||
@@ -149,11 +148,10 @@ 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(subtitleFilename);
|
const ext = path.extname(safeName);
|
||||||
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
||||||
let targetPath = path.join(mediaDir, subtitleFilename);
|
let targetPath = path.join(mediaDir, safeName);
|
||||||
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;
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
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,12 +87,6 @@ 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();
|
||||||
@@ -107,6 +101,12 @@ 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,7 +111,8 @@ 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.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
assert.equal(calls[0], 'loadYomitanExtension');
|
||||||
|
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,7 +39,6 @@ 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,39 +129,3 @@ 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/promises';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
export interface FrequencyDictionaryLookupOptions {
|
export interface FrequencyDictionaryLookupOptions {
|
||||||
@@ -13,17 +13,6 @@ 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();
|
||||||
@@ -104,22 +93,16 @@ function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEntriesToMap(
|
function addEntriesToMap(
|
||||||
rawEntries: unknown,
|
rawEntries: unknown,
|
||||||
terms: Map<string, number>,
|
terms: Map<string, number>,
|
||||||
): Promise<{ duplicateCount: number }> {
|
): { 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;
|
||||||
@@ -136,15 +119,15 @@ async function addEntriesToMap(
|
|||||||
return { duplicateCount };
|
return { duplicateCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDictionaryFromPath(
|
function collectDictionaryFromPath(
|
||||||
dictionaryPath: string,
|
dictionaryPath: string,
|
||||||
log: (message: string) => void,
|
log: (message: string) => void,
|
||||||
): Promise<Map<string, number>> {
|
): Map<string, number> {
|
||||||
const terms = new Map<string, number>();
|
const terms = new Map<string, number>();
|
||||||
|
|
||||||
let fileNames: string[];
|
let fileNames: string[];
|
||||||
try {
|
try {
|
||||||
fileNames = await fs.readdir(dictionaryPath);
|
fileNames = fs.readdirSync(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;
|
||||||
@@ -160,7 +143,7 @@ async function collectDictionaryFromPath(
|
|||||||
const bankPath = path.join(dictionaryPath, bankFile);
|
const bankPath = path.join(dictionaryPath, bankFile);
|
||||||
let rawText: string;
|
let rawText: string;
|
||||||
try {
|
try {
|
||||||
rawText = await fs.readFile(bankPath, 'utf-8');
|
rawText = fs.readFileSync(bankPath, 'utf-8');
|
||||||
} catch {
|
} catch {
|
||||||
log(`Failed to read frequency dictionary file ${bankPath}`);
|
log(`Failed to read frequency dictionary file ${bankPath}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -168,7 +151,6 @@ async 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}`);
|
||||||
@@ -176,7 +158,7 @@ async function collectDictionaryFromPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beforeSize = terms.size;
|
const beforeSize = terms.size;
|
||||||
const { duplicateCount } = await addEntriesToMap(rawEntries, terms);
|
const { duplicateCount } = 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${
|
||||||
@@ -203,11 +185,11 @@ export async function createFrequencyDictionaryLookup(
|
|||||||
let isDirectory = false;
|
let isDirectory = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isDirectory = (await fs.stat(dictionaryPath)).isDirectory();
|
if (!fs.existsSync(dictionaryPath)) {
|
||||||
} 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)}`,
|
||||||
);
|
);
|
||||||
@@ -219,7 +201,7 @@ export async function createFrequencyDictionaryLookup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
foundDictionaryPathCount += 1;
|
foundDictionaryPathCount += 1;
|
||||||
const terms = await collectDictionaryFromPath(dictionaryPath, options.log);
|
const terms = 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,31 +46,23 @@ 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 = (
|
const deletedSessionEvents = (db
|
||||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff) as {
|
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||||
changes: number;
|
.run(eventCutoff) as { changes: number }).changes;
|
||||||
}
|
const deletedTelemetryRows = (db
|
||||||
).changes;
|
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||||
const deletedTelemetryRows = (
|
.run(telemetryCutoff) as { changes: number }).changes;
|
||||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as {
|
const deletedDailyRows = (db
|
||||||
changes: number;
|
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
|
||||||
}
|
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }).changes;
|
||||||
).changes;
|
const deletedMonthlyRows = (db
|
||||||
const deletedDailyRows = (
|
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
|
||||||
db
|
.run(toMonthKey(monthCutoff)) as { changes: number }).changes;
|
||||||
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
|
const deletedEndedSessions = (db
|
||||||
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }
|
.prepare(
|
||||||
).changes;
|
`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`,
|
||||||
const deletedMonthlyRows = (
|
)
|
||||||
db
|
.run(telemetryCutoff) as { changes: number }).changes;
|
||||||
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
|
|
||||||
.run(toMonthKey(monthCutoff)) as { changes: number }
|
|
||||||
).changes;
|
|
||||||
const deletedEndedSessions = (
|
|
||||||
db
|
|
||||||
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
|
||||||
.run(telemetryCutoff) as { changes: number }
|
|
||||||
).changes;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deletedSessionEvents,
|
deletedSessionEvents,
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ 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(
|
assert.equal(result.words.every((entry) => entry.reading === ''), true);
|
||||||
result.words.every((entry) => entry.reading === ''),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.deepEqual(new Set(result.kanji), new Set(['你', '好', '猫']));
|
assert.deepEqual(new Set(result.kanji), new Set(['你', '好', '猫']));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ 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 =
|
const tokenPattern = /[A-Za-z0-9']+|[\u3040-\u30ff]+|[\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df]+/g;
|
||||||
/[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());
|
||||||
|
|||||||
@@ -19,8 +19,15 @@ export function startSessionRecord(
|
|||||||
CREATED_DATE, LAST_UPDATE_DATE
|
CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, nowMs);
|
.run(
|
||||||
|
sessionUuid,
|
||||||
|
videoId,
|
||||||
|
startedAtMs,
|
||||||
|
SESSION_STATUS_ACTIVE,
|
||||||
|
startedAtMs,
|
||||||
|
nowMs,
|
||||||
|
);
|
||||||
const sessionId = Number(result.lastInsertRowid);
|
const sessionId = Number(result.lastInsertRowid);
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ 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('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
.prepare(
|
||||||
|
'SELECT state_value FROM imm_rollup_state WHERE state_key = ?',
|
||||||
|
)
|
||||||
.get('last_rollup_sample_ms') as {
|
.get('last_rollup_sample_ms') as {
|
||||||
state_value: number;
|
state_value: number;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -186,9 +188,7 @@ 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(
|
.prepare('SELECT headword, frequency, first_seen, last_seen FROM imm_words WHERE headword = ?')
|
||||||
'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,7 +426,11 @@ export function getOrCreateVideoRecord(
|
|||||||
LAST_UPDATE_DATE = ?
|
LAST_UPDATE_DATE = ?
|
||||||
WHERE video_id = ?
|
WHERE video_id = ?
|
||||||
`,
|
`,
|
||||||
).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
|
).run(
|
||||||
|
details.canonicalTitle || 'unknown',
|
||||||
|
Date.now(),
|
||||||
|
existing.video_id,
|
||||||
|
);
|
||||||
return existing.video_id;
|
return existing.video_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,11 +129,7 @@ interface QueuedKanjiWrite {
|
|||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueuedWrite =
|
export type QueuedWrite = QueuedTelemetryWrite | QueuedEventWrite | QueuedWordWrite | QueuedKanjiWrite;
|
||||||
| QueuedTelemetryWrite
|
|
||||||
| QueuedEventWrite
|
|
||||||
| QueuedWordWrite
|
|
||||||
| QueuedKanjiWrite;
|
|
||||||
|
|
||||||
export interface VideoMetadata {
|
export interface VideoMetadata {
|
||||||
sourceType: number;
|
sourceType: number;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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,8 +13,6 @@ 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');
|
||||||
@@ -32,9 +30,6 @@ 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);
|
||||||
},
|
},
|
||||||
@@ -73,21 +68,6 @@ 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,8 +12,6 @@ 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;
|
||||||
@@ -21,7 +19,6 @@ 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;
|
||||||
@@ -49,9 +46,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,20 +64,6 @@ 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,10 +87,6 @@ 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: [
|
||||||
@@ -129,64 +125,6 @@ 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,29 +370,21 @@ 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: options.recursive === false ? 'false' : 'true',
|
Recursive: 'true',
|
||||||
IncludeItemTypes: includeItemTypes,
|
IncludeItemTypes: 'Movie,Episode,Audio',
|
||||||
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 (normalizedSearchTerm) {
|
if (options.searchTerm?.trim()) {
|
||||||
query.set('SearchTerm', normalizedSearchTerm);
|
query.set('SearchTerm', options.searchTerm.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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}`;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 'node:fs/promises';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import type { JlptLevel } from '../../types';
|
import type { JlptLevel } from '../../types';
|
||||||
@@ -24,17 +24,6 @@ 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();
|
||||||
@@ -47,12 +36,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEntriesToMap(
|
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,
|
||||||
): Promise<void> {
|
): void {
|
||||||
const shouldUpdateLevel = (
|
const shouldUpdateLevel = (
|
||||||
existingLevel: JlptLevel | undefined,
|
existingLevel: JlptLevel | undefined,
|
||||||
incomingLevel: JlptLevel,
|
incomingLevel: JlptLevel,
|
||||||
@@ -64,13 +53,7 @@ async 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;
|
||||||
}
|
}
|
||||||
@@ -101,31 +84,22 @@ async function addEntriesToMap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDictionaryFromPath(
|
function collectDictionaryFromPath(
|
||||||
dictionaryPath: string,
|
dictionaryPath: string,
|
||||||
log: (message: string) => void,
|
log: (message: string) => void,
|
||||||
): Promise<Map<string, JlptLevel>> {
|
): 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);
|
||||||
try {
|
if (!fs.existsSync(bankPath)) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (isErrorCode(error, 'ENOENT')) {
|
|
||||||
log(`JLPT bank file missing for ${bank.level}: ${bankPath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
log(`Failed to inspect JLPT bank file ${bankPath}: ${String(error)}`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawText: string;
|
let rawText: string;
|
||||||
try {
|
try {
|
||||||
rawText = await fs.readFile(bankPath, 'utf-8');
|
rawText = fs.readFileSync(bankPath, 'utf-8');
|
||||||
} catch {
|
} catch {
|
||||||
log(`Failed to read JLPT bank file ${bankPath}`);
|
log(`Failed to read JLPT bank file ${bankPath}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -133,7 +107,6 @@ async 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}`);
|
||||||
@@ -146,7 +119,7 @@ async function collectDictionaryFromPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beforeSize = terms.size;
|
const beforeSize = terms.size;
|
||||||
await addEntriesToMap(rawEntries, bank.level, terms, log);
|
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}`);
|
||||||
}
|
}
|
||||||
@@ -164,21 +137,17 @@ 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);
|
||||||
let isDirectory = false;
|
if (!fs.existsSync(dictionaryPath)) {
|
||||||
try {
|
continue;
|
||||||
isDirectory = (await fs.stat(dictionaryPath)).isDirectory();
|
}
|
||||||
} catch (error) {
|
|
||||||
if (isErrorCode(error, 'ENOENT')) {
|
if (!fs.statSync(dictionaryPath).isDirectory()) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
options.log(`Failed to inspect JLPT dictionary path ${dictionaryPath}: ${String(error)}`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isDirectory) continue;
|
|
||||||
|
|
||||||
foundDictionaryPathCount += 1;
|
foundDictionaryPathCount += 1;
|
||||||
|
|
||||||
const terms = await collectDictionaryFromPath(dictionaryPath, options.log);
|
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
|
||||||
if (terms.size > 0) {
|
if (terms.size > 0) {
|
||||||
resolvedBanks.push(dictionaryPath);
|
resolvedBanks.push(dictionaryPath);
|
||||||
foundBankCount += 1;
|
foundBankCount += 1;
|
||||||
|
|||||||
@@ -57,26 +57,6 @@ 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,7 +134,6 @@ 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;
|
||||||
@@ -331,7 +330,6 @@ 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) => {
|
||||||
@@ -366,7 +364,6 @@ 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,7 +39,6 @@ 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,
|
||||||
|
|||||||
@@ -209,73 +209,10 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
|||||||
assert.ok(ffArgs.includes(primaryPath));
|
assert.ok(ffArgs.includes(primaryPath));
|
||||||
assert.ok(ffArgs.includes('--reference-stream'));
|
assert.ok(ffArgs.includes('--reference-stream'));
|
||||||
assert.ok(ffArgs.includes('0:2'));
|
assert.ok(ffArgs.includes('0:2'));
|
||||||
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
|
||||||
assert.equal(ffOutputFlagIndex >= 0, true);
|
|
||||||
assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath);
|
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runSubsyncManual writes deterministic _retimed filename when replace is false', async () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-no-replace-'));
|
|
||||||
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
|
|
||||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
|
||||||
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
|
||||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
|
||||||
const videoPath = path.join(tmpDir, 'video.mkv');
|
|
||||||
const primaryPath = path.join(tmpDir, 'episode.ja.srt');
|
|
||||||
|
|
||||||
fs.writeFileSync(videoPath, 'video');
|
|
||||||
fs.writeFileSync(primaryPath, 'sub');
|
|
||||||
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
|
||||||
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
|
|
||||||
writeExecutableScript(
|
|
||||||
ffsubsyncPath,
|
|
||||||
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const deps = makeDeps({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentAudioStreamIndex: null,
|
|
||||||
send: () => {},
|
|
||||||
requestProperty: async (name: string) => {
|
|
||||||
if (name === 'path') return videoPath;
|
|
||||||
if (name === 'sid') return 1;
|
|
||||||
if (name === 'secondary-sid') return null;
|
|
||||||
if (name === 'track-list') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: 'sub',
|
|
||||||
selected: true,
|
|
||||||
external: true,
|
|
||||||
'external-filename': primaryPath,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
defaultMode: 'manual',
|
|
||||||
alassPath,
|
|
||||||
ffsubsyncPath,
|
|
||||||
ffmpegPath,
|
|
||||||
replace: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
|
||||||
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
|
||||||
assert.equal(ffOutputFlagIndex >= 0, true);
|
|
||||||
const outputPath = ffArgs[ffOutputFlagIndex + 1];
|
|
||||||
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
||||||
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
@@ -344,76 +281,6 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
assert.equal(alassArgs[1], primaryPath);
|
assert.equal(alassArgs[1], primaryPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
|
||||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
|
||||||
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
|
||||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
|
||||||
const videoPath = path.join(tmpDir, 'video.mkv');
|
|
||||||
const primaryPath = path.join(tmpDir, 'primary.srt');
|
|
||||||
|
|
||||||
fs.writeFileSync(videoPath, 'video');
|
|
||||||
fs.writeFileSync(primaryPath, 'sub');
|
|
||||||
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
|
||||||
writeExecutableScript(
|
|
||||||
ffmpegPath,
|
|
||||||
'#!/bin/sh\nout=""\nfor arg in "$@"; do out="$arg"; done\nif [ -n "$out" ]; then : > "$out"; fi\nexit 0\n',
|
|
||||||
);
|
|
||||||
writeExecutableScript(
|
|
||||||
alassPath,
|
|
||||||
'#!/bin/sh\nsleep 0.2\nif [ ! -f "$1" ]; then echo "missing reference subtitle" >&2; exit 1; fi\nif [ ! -f "$2" ]; then echo "missing input subtitle" >&2; exit 1; fi\n: > "$3"\nexit 0\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
const sentCommands: Array<Array<string | number>> = [];
|
|
||||||
const deps = makeDeps({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentAudioStreamIndex: null,
|
|
||||||
send: (payload) => {
|
|
||||||
sentCommands.push(payload.command);
|
|
||||||
},
|
|
||||||
requestProperty: async (name: string) => {
|
|
||||||
if (name === 'path') return videoPath;
|
|
||||||
if (name === 'sid') return 1;
|
|
||||||
if (name === 'secondary-sid') return null;
|
|
||||||
if (name === 'track-list') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: 'sub',
|
|
||||||
selected: true,
|
|
||||||
external: true,
|
|
||||||
'external-filename': primaryPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: 'sub',
|
|
||||||
selected: false,
|
|
||||||
external: false,
|
|
||||||
'ff-index': 2,
|
|
||||||
codec: 'ass',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
defaultMode: 'manual',
|
|
||||||
alassPath,
|
|
||||||
ffsubsyncPath,
|
|
||||||
ffmpegPath,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(result.message, 'Subtitle synchronized with alass');
|
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
||||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRetimedPath(subPath: string, replace: boolean): string {
|
function buildRetimedPath(subPath: string): string {
|
||||||
if (replace) return subPath;
|
|
||||||
const parsed = path.parse(subPath);
|
const parsed = path.parse(subPath);
|
||||||
return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`);
|
const suffix = `_retimed_${Date.now()}`;
|
||||||
|
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAlassSync(
|
async function runAlassSync(
|
||||||
@@ -265,8 +265,7 @@ async function subsyncToReference(
|
|||||||
context.videoPath,
|
context.videoPath,
|
||||||
context.primaryTrack,
|
context.primaryTrack,
|
||||||
);
|
);
|
||||||
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
const outputPath = buildRetimedPath(primaryExtraction.path);
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
@@ -390,7 +389,7 @@ export async function runSubsyncManual(
|
|||||||
let sourceExtraction: FileExtractionResult | null = null;
|
let sourceExtraction: FileExtractionResult | null = null;
|
||||||
try {
|
try {
|
||||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
||||||
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceExtraction) {
|
if (sourceExtraction) {
|
||||||
cleanupTemporaryFile(sourceExtraction);
|
cleanupTemporaryFile(sourceExtraction);
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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/);
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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,97 +297,6 @@ 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(
|
||||||
'鍛えた',
|
'鍛えた',
|
||||||
@@ -400,11 +309,6 @@ 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 [];
|
||||||
}
|
}
|
||||||
@@ -447,58 +351,6 @@ 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(
|
||||||
'無人',
|
'無人',
|
||||||
@@ -2162,48 +2014,6 @@ 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(
|
||||||
'猫',
|
'猫',
|
||||||
@@ -2370,6 +2180,7 @@ 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,7 +51,6 @@ 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 {
|
||||||
@@ -79,7 +78,6 @@ 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 {
|
||||||
@@ -92,14 +90,13 @@ interface TokenizerAnnotationOptions {
|
|||||||
pos2Exclusions: ReadonlySet<string>;
|
pos2Exclusions: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parserEnrichmentWorkerRuntimeModulePromise: Promise<
|
let parserEnrichmentWorkerRuntimeModulePromise:
|
||||||
typeof import('./tokenizer/parser-enrichment-worker-runtime')
|
| Promise<typeof import('./tokenizer/parser-enrichment-worker-runtime')>
|
||||||
> | null = null;
|
| null = null;
|
||||||
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null =
|
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null = null;
|
||||||
null;
|
let parserEnrichmentFallbackModulePromise:
|
||||||
let parserEnrichmentFallbackModulePromise: Promise<
|
| Promise<typeof import('./tokenizer/parser-enrichment-stage')>
|
||||||
typeof import('./tokenizer/parser-enrichment-stage')
|
| null = null;
|
||||||
> | null = null;
|
|
||||||
const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
|
const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
|
||||||
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
||||||
);
|
);
|
||||||
@@ -107,10 +104,7 @@ const DEFAULT_ANNOTATION_POS2_EXCLUSIONS = resolveAnnotationPos2ExclusionSet(
|
|||||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getKnownWordLookup(
|
function getKnownWordLookup(deps: TokenizerServiceDeps, options: TokenizerAnnotationOptions): (text: string) => boolean {
|
||||||
deps: TokenizerServiceDeps,
|
|
||||||
options: TokenizerAnnotationOptions,
|
|
||||||
): (text: string) => boolean {
|
|
||||||
if (!options.nPlusOneEnabled) {
|
if (!options.nPlusOneEnabled) {
|
||||||
return () => false;
|
return () => false;
|
||||||
}
|
}
|
||||||
@@ -130,8 +124,7 @@ async function enrichTokensWithMecabAsync(
|
|||||||
mecabTokens: MergedToken[] | null,
|
mecabTokens: MergedToken[] | null,
|
||||||
): Promise<MergedToken[]> {
|
): Promise<MergedToken[]> {
|
||||||
if (!parserEnrichmentWorkerRuntimeModulePromise) {
|
if (!parserEnrichmentWorkerRuntimeModulePromise) {
|
||||||
parserEnrichmentWorkerRuntimeModulePromise =
|
parserEnrichmentWorkerRuntimeModulePromise = import('./tokenizer/parser-enrichment-worker-runtime');
|
||||||
import('./tokenizer/parser-enrichment-worker-runtime');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +183,8 @@ export function createTokenizerDepsRuntime(
|
|||||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||||
getJlptEnabled: options.getJlptEnabled,
|
getJlptEnabled: options.getJlptEnabled,
|
||||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
getFrequencyDictionaryMatchMode:
|
||||||
|
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),
|
||||||
@@ -217,11 +211,11 @@ export function createTokenizerDepsRuntime(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode(), false);
|
const isKnownWordLookup = options.getNPlusOneEnabled?.() === false ? () => false : options.isKnownWord;
|
||||||
|
return mergeTokens(rawTokens, isKnownWordLookup, options.getKnownWordMatchMode());
|
||||||
},
|
},
|
||||||
enrichTokensWithMecab: async (tokens, mecabTokens) =>
|
enrichTokensWithMecab: async (tokens, mecabTokens) =>
|
||||||
enrichTokensWithMecabAsync(tokens, mecabTokens),
|
enrichTokensWithMecabAsync(tokens, mecabTokens),
|
||||||
onTokenizationReady: options.onTokenizationReady,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,50 +249,6 @@ 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,
|
||||||
@@ -326,19 +276,17 @@ function buildYomitanFrequencyTermReadingList(
|
|||||||
tokens: MergedToken[],
|
tokens: MergedToken[],
|
||||||
matchMode: FrequencyDictionaryMatchMode,
|
matchMode: FrequencyDictionaryMatchMode,
|
||||||
): Array<{ term: string; reading: string | null }> {
|
): Array<{ term: string; reading: string | null }> {
|
||||||
const termReadingList: Array<{ term: string; reading: string | null }> = [];
|
return tokens
|
||||||
for (const token of tokens) {
|
.map((token) => {
|
||||||
const term = resolveFrequencyLookupText(token, matchMode).trim();
|
const term = resolveFrequencyLookupText(token, matchMode).trim();
|
||||||
if (!term) {
|
if (!term) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
|
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(
|
||||||
@@ -352,8 +300,7 @@ function buildYomitanFrequencyRankMap(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dictionaryPriority =
|
const dictionaryPriority =
|
||||||
typeof frequency.dictionaryPriority === 'number' &&
|
typeof frequency.dictionaryPriority === 'number' && Number.isFinite(frequency.dictionaryPriority)
|
||||||
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);
|
||||||
@@ -480,25 +427,19 @@ 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, normalizedSelectedTokens);
|
logSelectedYomitanGroups(text, selectedTokens);
|
||||||
}
|
}
|
||||||
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(
|
||||||
normalizedSelectedTokens,
|
selectedTokens,
|
||||||
frequencyMatchMode,
|
frequencyMatchMode,
|
||||||
);
|
);
|
||||||
const yomitanFrequencies = await requestYomitanTermFrequencies(
|
const yomitanFrequencies = await requestYomitanTermFrequencies(termReadingList, deps, logger);
|
||||||
termReadingList,
|
|
||||||
deps,
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
return buildYomitanFrequencyRankMap(yomitanFrequencies);
|
return buildYomitanFrequencyRankMap(yomitanFrequencies);
|
||||||
})()
|
})()
|
||||||
: Promise.resolve(new Map<string, number>());
|
: Promise.resolve(new Map<string, number>());
|
||||||
@@ -508,19 +449,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(normalizedSelectedTokens, mecabTokens);
|
return await enrichTokensWithMecab(selectedTokens, 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=${normalizedSelectedTokens.length}`,
|
`tokenCount=${selectedTokens.length}`,
|
||||||
`textLength=${text.length}`,
|
`textLength=${text.length}`,
|
||||||
);
|
);
|
||||||
return normalizedSelectedTokens;
|
return selectedTokens;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
: Promise.resolve(normalizedSelectedTokens);
|
: Promise.resolve(selectedTokens);
|
||||||
|
|
||||||
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([
|
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([
|
||||||
frequencyRankPromise,
|
frequencyRankPromise,
|
||||||
|
|||||||
@@ -48,77 +48,3 @@ 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,120 +6,6 @@ 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) {
|
||||||
@@ -143,129 +29,87 @@ function joinUniqueTags(values: Array<string | undefined>): string | undefined {
|
|||||||
return unique.join('|');
|
return unique.join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickClosestMecabPosMetadataBySurface(
|
function pickClosestMecabPosMetadata(
|
||||||
token: MergedToken,
|
token: MergedToken,
|
||||||
candidates: IndexedMecabToken[] | undefined,
|
mecabTokens: MergedToken[],
|
||||||
): MecabPosMetadata | null {
|
): MecabPosMetadata | null {
|
||||||
if (!candidates || candidates.length === 0) {
|
if (mecabTokens.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: IndexedMecabToken | null = null;
|
let bestSurfaceMatchToken: MergedToken | 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;
|
|
||||||
|
|
||||||
const nearestStartIndex = lowerBoundByStart(candidates, tokenStart);
|
for (const mecabToken of mecabTokens) {
|
||||||
let left = nearestStartIndex - 1;
|
if (!mecabToken.pos1) {
|
||||||
let right = nearestStartIndex;
|
continue;
|
||||||
|
|
||||||
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 (leftDistance === nearestDistance && left >= 0) {
|
if (mecabToken.surface !== token.surface) {
|
||||||
const candidate = candidates[left]!;
|
continue;
|
||||||
const startDistance = Math.abs(candidate.start - tokenStart);
|
|
||||||
const endDistance = Math.abs(candidate.end - tokenEnd);
|
|
||||||
if (
|
|
||||||
startDistance < bestSurfaceMatchDistance ||
|
|
||||||
(startDistance === bestSurfaceMatchDistance &&
|
|
||||||
(endDistance < bestSurfaceMatchEndDistance ||
|
|
||||||
(endDistance === bestSurfaceMatchEndDistance &&
|
|
||||||
candidate.index < bestSurfaceMatchIndex)))
|
|
||||||
) {
|
|
||||||
bestSurfaceMatchDistance = startDistance;
|
|
||||||
bestSurfaceMatchEndDistance = endDistance;
|
|
||||||
bestSurfaceMatchIndex = candidate.index;
|
|
||||||
bestSurfaceMatchToken = candidate;
|
|
||||||
}
|
|
||||||
left -= 1;
|
|
||||||
}
|
}
|
||||||
if (rightDistance === nearestDistance && right < candidates.length) {
|
|
||||||
const candidate = candidates[right]!;
|
const mecabStart = mecabToken.startPos ?? 0;
|
||||||
const startDistance = Math.abs(candidate.start - tokenStart);
|
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
|
||||||
const endDistance = Math.abs(candidate.end - tokenEnd);
|
const startDistance = Math.abs(mecabStart - tokenStart);
|
||||||
if (
|
const endDistance = Math.abs(mecabEnd - tokenEnd);
|
||||||
startDistance < bestSurfaceMatchDistance ||
|
|
||||||
(startDistance === bestSurfaceMatchDistance &&
|
if (
|
||||||
(endDistance < bestSurfaceMatchEndDistance ||
|
startDistance < bestSurfaceMatchDistance ||
|
||||||
(endDistance === bestSurfaceMatchEndDistance &&
|
(startDistance === bestSurfaceMatchDistance && endDistance < bestSurfaceMatchEndDistance)
|
||||||
candidate.index < bestSurfaceMatchIndex)))
|
) {
|
||||||
) {
|
bestSurfaceMatchDistance = startDistance;
|
||||||
bestSurfaceMatchDistance = startDistance;
|
bestSurfaceMatchEndDistance = endDistance;
|
||||||
bestSurfaceMatchEndDistance = endDistance;
|
bestSurfaceMatchToken = mecabToken;
|
||||||
bestSurfaceMatchIndex = candidate.index;
|
|
||||||
bestSurfaceMatchToken = candidate;
|
|
||||||
}
|
|
||||||
right += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestSurfaceMatchToken !== null) {
|
if (bestSurfaceMatchToken) {
|
||||||
return {
|
return {
|
||||||
pos1: bestSurfaceMatchToken.pos1,
|
pos1: bestSurfaceMatchToken.pos1 as string,
|
||||||
pos2: bestSurfaceMatchToken.pos2,
|
pos2: bestSurfaceMatchToken.pos2,
|
||||||
pos3: bestSurfaceMatchToken.pos3,
|
pos3: bestSurfaceMatchToken.pos3,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
let bestToken: MergedToken | null = 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;
|
||||||
let bestIndex = Number.MAX_SAFE_INTEGER;
|
const overlappingTokens: MergedToken[] = [];
|
||||||
const overlappingTokens: IndexedMecabToken[] = [];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const mecabToken of mecabTokens) {
|
||||||
const mecabStart = candidate.start;
|
if (!mecabToken.pos1) {
|
||||||
const mecabEnd = candidate.end;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(candidate);
|
overlappingTokens.push(mecabToken);
|
||||||
|
|
||||||
const span = mecabEnd - mecabStart;
|
const span = mecabEnd - mecabStart;
|
||||||
const startDistance = Math.abs(mecabStart - tokenStart);
|
|
||||||
if (
|
if (
|
||||||
overlap > bestOverlap ||
|
overlap > bestOverlap ||
|
||||||
(overlap === bestOverlap &&
|
(overlap === bestOverlap &&
|
||||||
(startDistance < bestStartDistance ||
|
(Math.abs(mecabStart - tokenStart) < bestStartDistance ||
|
||||||
(startDistance === bestStartDistance &&
|
(Math.abs(mecabStart - tokenStart) === bestStartDistance &&
|
||||||
(span > bestSpan ||
|
(span > bestSpan || (span === bestSpan && mecabStart < bestStart)))))
|
||||||
(span === bestSpan &&
|
|
||||||
(mecabStart < bestStart ||
|
|
||||||
(mecabStart === bestStart && candidate.index < bestIndex)))))))
|
|
||||||
) {
|
) {
|
||||||
bestOverlap = overlap;
|
bestOverlap = overlap;
|
||||||
bestSpan = span;
|
bestSpan = span;
|
||||||
bestStartDistance = startDistance;
|
bestStartDistance = Math.abs(mecabStart - tokenStart);
|
||||||
bestStart = mecabStart;
|
bestStart = mecabStart;
|
||||||
bestIndex = candidate.index;
|
bestToken = mecabToken;
|
||||||
bestToken = candidate;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,21 +117,12 @@ function pickClosestMecabPosMetadataByOverlap(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlappingTokensByMecabOrder = overlappingTokens
|
const overlapPos1 = joinUniqueTags(overlappingTokens.map((token) => token.pos1));
|
||||||
.slice()
|
const overlapPos2 = joinUniqueTags(overlappingTokens.map((token) => token.pos2));
|
||||||
.sort((left, right) => left.index - right.index);
|
const overlapPos3 = joinUniqueTags(overlappingTokens.map((token) => token.pos3));
|
||||||
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,
|
pos1: overlapPos1 ?? (bestToken.pos1 as string),
|
||||||
pos2: overlapPos2 ?? bestToken.pos2,
|
pos2: overlapPos2 ?? bestToken.pos2,
|
||||||
pos3: overlapPos3 ?? bestToken.pos3,
|
pos3: overlapPos3 ?? bestToken.pos3,
|
||||||
};
|
};
|
||||||
@@ -295,9 +130,13 @@ function pickClosestMecabPosMetadataByOverlap(
|
|||||||
|
|
||||||
function fillMissingPos1BySurfaceSequence(
|
function fillMissingPos1BySurfaceSequence(
|
||||||
tokens: MergedToken[],
|
tokens: MergedToken[],
|
||||||
byTrimmedSurface: Map<string, IndexedMecabToken[]>,
|
mecabTokens: MergedToken[],
|
||||||
): MergedToken[] {
|
): MergedToken[] {
|
||||||
if (byTrimmedSurface.size === 0) {
|
const indexedMecabTokens = mecabTokens
|
||||||
|
.map((token, index) => ({ token, index }))
|
||||||
|
.filter(({ token }) => token.pos1 && token.surface.trim().length > 0);
|
||||||
|
|
||||||
|
if (indexedMecabTokens.length === 0) {
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,13 +151,27 @@ function fillMissingPos1BySurfaceSequence(
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = byTrimmedSurface.get(surface);
|
let best: { token: MergedToken; index: number } | null = null;
|
||||||
if (!candidates || candidates.length === 0) {
|
for (const candidate of indexedMecabTokens) {
|
||||||
return token;
|
if (candidate.token.surface !== surface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.index < cursor) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
best = { token: candidate.token, index: candidate.index };
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const atOrAfterCursorIndex = lowerBoundByIndex(candidates, cursor);
|
if (!best) {
|
||||||
const best = candidates[atOrAfterCursorIndex] ?? candidates[0];
|
for (const candidate of indexedMecabTokens) {
|
||||||
|
if (candidate.token.surface !== surface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
best = { token: candidate.token, index: candidate.index };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!best) {
|
if (!best) {
|
||||||
return token;
|
return token;
|
||||||
@@ -327,41 +180,13 @@ function fillMissingPos1BySurfaceSequence(
|
|||||||
cursor = best.index + 1;
|
cursor = best.index + 1;
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
pos1: best.pos1,
|
pos1: best.token.pos1,
|
||||||
pos2: best.pos2,
|
pos2: best.token.pos2,
|
||||||
pos3: best.pos3,
|
pos3: best.token.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,
|
||||||
@@ -374,36 +199,12 @@ export function enrichTokensWithMecabPos1(
|
|||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = buildMecabLookup(mecabTokens);
|
const overlapEnriched = tokens.map((token) => {
|
||||||
if (lookup.indexedTokens.length === 0) {
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataByTokenIndex = new Map<number, MecabPosMetadata>();
|
|
||||||
|
|
||||||
for (const [index, token] of tokens.entries()) {
|
|
||||||
if (token.pos1) {
|
if (token.pos1) {
|
||||||
continue;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const surfaceMetadata = pickClosestMecabPosMetadataBySurface(
|
const metadata = pickClosestMecabPosMetadata(token, mecabTokens);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -416,5 +217,5 @@ export function enrichTokensWithMecabPos1(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return fillMissingPos1BySurfaceSequence(overlapEnriched, lookup.byTrimmedSurface);
|
return fillMissingPos1BySurfaceSequence(overlapEnriched, mecabTokens);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@@ -44,19 +43,15 @@ test('syncYomitanDefaultAnkiServer updates default profile server when script re
|
|||||||
assert.equal(infoLogs.length, 1);
|
assert.equal(infoLogs.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncYomitanDefaultAnkiServer returns true when script reports no change', async () => {
|
test('syncYomitanDefaultAnkiServer returns false when script reports no change', async () => {
|
||||||
const deps = createDeps(async () => ({ updated: false }));
|
const deps = createDeps(async () => ({ updated: false }));
|
||||||
let infoLogCount = 0;
|
|
||||||
|
|
||||||
const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
||||||
error: () => undefined,
|
error: () => undefined,
|
||||||
info: () => {
|
info: () => undefined,
|
||||||
infoLogCount += 1;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(synced, true);
|
assert.equal(updated, false);
|
||||||
assert.equal(infoLogCount, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
||||||
@@ -157,102 +152,6 @@ 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) => {
|
||||||
@@ -347,32 +246,3 @@ 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,10 +39,7 @@ 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<
|
const yomitanFrequencyCacheByWindow = new WeakMap<BrowserWindow, Map<string, YomitanTermFrequency[]>>();
|
||||||
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');
|
||||||
@@ -90,7 +87,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] ?? '');
|
||||||
@@ -148,7 +145,11 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
|
|||||||
: Number.MAX_SAFE_INTEGER;
|
: Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
const reading =
|
const reading =
|
||||||
value.reading === null ? null : typeof value.reading === 'string' ? value.reading : null;
|
value.reading === null
|
||||||
|
? null
|
||||||
|
: typeof value.reading === 'string'
|
||||||
|
? value.reading
|
||||||
|
: null;
|
||||||
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
|
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
|
||||||
const displayValueParsed = value.displayValueParsed === true;
|
const displayValueParsed = value.displayValueParsed === true;
|
||||||
|
|
||||||
@@ -163,9 +164,7 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTermReadingList(
|
function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): YomitanTermReadingPair[] {
|
||||||
termReadingList: YomitanTermReadingPair[],
|
|
||||||
): YomitanTermReadingPair[] {
|
|
||||||
const normalized: YomitanTermReadingPair[] = [];
|
const normalized: YomitanTermReadingPair[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -175,9 +174,7 @@ function normalizeTermReadingList(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const reading =
|
const reading =
|
||||||
typeof pair.reading === 'string' && pair.reading.trim().length > 0
|
typeof pair.reading === 'string' && pair.reading.trim().length > 0 ? pair.reading.trim() : null;
|
||||||
? pair.reading.trim()
|
|
||||||
: null;
|
|
||||||
const key = `${term}\u0000${reading ?? ''}`;
|
const key = `${term}\u0000${reading ?? ''}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -301,9 +298,7 @@ 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
|
typeof entry.reading === 'string' && entry.reading.trim().length > 0 ? entry.reading.trim() : null;
|
||||||
? 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) {
|
||||||
@@ -534,7 +529,7 @@ export async function requestYomitanParseResults(
|
|||||||
optionsContext: { index: ${metadata.profileIndex} },
|
optionsContext: { index: ${metadata.profileIndex} },
|
||||||
scanLength: ${metadata.scanLength},
|
scanLength: ${metadata.scanLength},
|
||||||
useInternalParser: true,
|
useInternalParser: true,
|
||||||
useMecabParser: false
|
useMecabParser: true
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
`
|
`
|
||||||
@@ -569,7 +564,7 @@ export async function requestYomitanParseResults(
|
|||||||
optionsContext: { index: profileIndex },
|
optionsContext: { index: profileIndex },
|
||||||
scanLength,
|
scanLength,
|
||||||
useInternalParser: true,
|
useInternalParser: true,
|
||||||
useMecabParser: false
|
useMecabParser: true
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
@@ -583,144 +578,6 @@ 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,
|
||||||
@@ -765,83 +622,148 @@ export async function requestYomitanTermFrequencies(
|
|||||||
return buildCachedResult();
|
return buildCachedResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedEntries = await fetchYomitanTermFrequencies(
|
if (metadata && metadata.dictionaries.length > 0) {
|
||||||
parserWindow,
|
const script = `
|
||||||
missingTermReadingList,
|
(async () => {
|
||||||
metadata,
|
const invoke = (action, params) =>
|
||||||
logger,
|
new Promise((resolve, reject) => {
|
||||||
);
|
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||||
if (fetchedEntries === null) {
|
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(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();
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheFrequencyEntriesForPairs(frequencyCache, missingTermReadingList, fetchedEntries);
|
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 fallbackTermReadingList = normalizeTermReadingList(
|
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||||
missingTermReadingList
|
const profileIndex = optionsFull.profileCurrent;
|
||||||
.filter((pair) => pair.reading !== null)
|
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? [];
|
||||||
.map((pair) => {
|
const dictionaryEntries = Array.isArray(dictionariesRaw)
|
||||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
? dictionariesRaw
|
||||||
const cachedEntries = frequencyCache.get(key);
|
.filter((entry) => entry && typeof entry === "object" && entry.enabled === true && typeof entry.name === "string")
|
||||||
if (cachedEntries && cachedEntries.length > 0) {
|
.map((entry, index) => ({
|
||||||
return null;
|
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;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const fallbackKey = makeTermReadingCacheKey(pair.term, null);
|
if (dictionaries.length === 0) {
|
||||||
const cachedFallback = frequencyCache.get(fallbackKey);
|
return [];
|
||||||
if (cachedFallback && cachedFallback.length > 0) {
|
}
|
||||||
frequencyCache.set(key, cachedFallback);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { term: pair.term, reading: null };
|
const rawFrequencies = await invoke("getTermFrequencies", {
|
||||||
})
|
termReadingList: ${JSON.stringify(missingTermReadingList)},
|
||||||
.filter((pair): pair is { term: string; reading: null } => pair !== null),
|
dictionaries
|
||||||
).filter((pair) => !frequencyCache.has(makeTermReadingCacheKey(pair.term, pair.reading)));
|
});
|
||||||
|
|
||||||
let fallbackFetchedEntries: YomitanTermFrequency[] = [];
|
if (!Array.isArray(rawFrequencies)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (fallbackTermReadingList.length > 0) {
|
return rawFrequencies
|
||||||
const fallbackFetchResult = await fetchYomitanTermFrequencies(
|
.filter((entry) => entry && typeof entry === "object")
|
||||||
parserWindow,
|
.map((entry) => ({
|
||||||
fallbackTermReadingList,
|
...entry,
|
||||||
metadata,
|
dictionaryPriority:
|
||||||
logger,
|
typeof entry.dictionary === "string" && dictionaryPriorityByName[entry.dictionary] !== undefined
|
||||||
);
|
? dictionaryPriorityByName[entry.dictionary]
|
||||||
if (fallbackFetchResult !== null) {
|
: Number.MAX_SAFE_INTEGER
|
||||||
fallbackFetchedEntries = fallbackFetchResult;
|
}));
|
||||||
cacheFrequencyEntriesForPairs(
|
})();
|
||||||
frequencyCache,
|
`;
|
||||||
fallbackTermReadingList,
|
|
||||||
fallbackFetchedEntries,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
|
||||||
|
const fetchedEntries = Array.isArray(rawResult)
|
||||||
|
? rawResult
|
||||||
|
.map((entry) => toYomitanTermFrequency(entry))
|
||||||
|
.filter((entry): entry is YomitanTermFrequency => entry !== null)
|
||||||
|
: [];
|
||||||
|
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
|
||||||
|
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
|
||||||
|
const missingTerms = new Set(missingTermReadingList.map((pair) => pair.term));
|
||||||
for (const pair of missingTermReadingList) {
|
for (const pair of missingTermReadingList) {
|
||||||
if (pair.reading === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||||
const cachedEntries = frequencyCache.get(key);
|
const exactEntries = groupedByPair.get(key);
|
||||||
if (cachedEntries && cachedEntries.length > 0) {
|
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||||
continue;
|
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||||
}
|
|
||||||
const fallbackEntries = frequencyCache.get(makeTermReadingCacheKey(pair.term, null));
|
|
||||||
if (fallbackEntries && fallbackEntries.length > 0) {
|
|
||||||
frequencyCache.set(key, fallbackEntries);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const cachedResult = buildCachedResult();
|
||||||
|
const unmatchedEntries = fetchedEntries.filter((entry) => !missingTerms.has(entry.term.trim()));
|
||||||
|
return [...cachedResult, ...unmatchedEntries];
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Yomitan term frequency request failed:', (err as Error).message);
|
||||||
|
return buildCachedResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
const allFetchedEntries = [...fetchedEntries, ...fallbackFetchedEntries];
|
|
||||||
const queriedTerms = new Set(
|
|
||||||
[...missingTermReadingList, ...fallbackTermReadingList].map((pair) => pair.term),
|
|
||||||
);
|
|
||||||
const cachedResult = buildCachedResult();
|
|
||||||
const unmatchedEntries = allFetchedEntries.filter(
|
|
||||||
(entry) => !queriedTerms.has(entry.term.trim()),
|
|
||||||
);
|
|
||||||
return [...cachedResult, ...unmatchedEntries];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncYomitanDefaultAnkiServer(
|
export async function syncYomitanDefaultAnkiServer(
|
||||||
@@ -924,11 +846,7 @@ 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;
|
||||||
}
|
}
|
||||||
const checkedWithoutUpdate =
|
return false;
|
||||||
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;
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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,13 +1,26 @@
|
|||||||
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 DEFAULT_TEXTHOOKER_PORT = 5174;
|
const BACKGROUND_ARG = '--background';
|
||||||
|
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), {
|
||||||
@@ -19,14 +32,4 @@ 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');
|
||||||
|
|||||||
168
src/main.ts
168
src/main.ts
@@ -331,7 +331,6 @@ import {
|
|||||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||||
createConfigHotReloadRuntime,
|
createConfigHotReloadRuntime,
|
||||||
createDiscordPresenceService,
|
createDiscordPresenceService,
|
||||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
|
||||||
createFieldGroupingOverlayRuntime,
|
createFieldGroupingOverlayRuntime,
|
||||||
createOverlayContentMeasurementStore,
|
createOverlayContentMeasurementStore,
|
||||||
createOverlayManager,
|
createOverlayManager,
|
||||||
@@ -854,30 +853,21 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH
|
|||||||
let autoPlayReadySignalMediaPath: string | null = null;
|
let autoPlayReadySignalMediaPath: string | null = null;
|
||||||
let autoPlayReadySignalGeneration = 0;
|
let autoPlayReadySignalGeneration = 0;
|
||||||
|
|
||||||
function maybeSignalPluginAutoplayReady(
|
function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
|
||||||
payload: SubtitleData,
|
|
||||||
options?: { forceWhilePaused?: boolean },
|
|
||||||
): void {
|
|
||||||
if (!payload.text.trim()) {
|
if (!payload.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mediaPath =
|
const mediaPath = appState.currentMediaPath;
|
||||||
appState.currentMediaPath?.trim() ||
|
if (!mediaPath) {
|
||||||
appState.mpvClient?.currentVideoPath?.trim() ||
|
return;
|
||||||
'__unknown__';
|
}
|
||||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
if (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> => {
|
||||||
@@ -892,9 +882,7 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
if (typeof pauseProperty === 'number') {
|
if (typeof pauseProperty === 'number') {
|
||||||
return pauseProperty !== 0;
|
return pauseProperty !== 0;
|
||||||
}
|
}
|
||||||
logger.debug(
|
logger.debug(`[autoplay-ready] unrecognized pause property for media ${mediaPath}: ${String(pauseProperty)}`);
|
||||||
`[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}`,
|
||||||
@@ -903,52 +891,55 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback: repeatedly try to release pause for a short window in case startup
|
// Fallback: unpause directly in case plugin readiness handler is unavailable/outdated.
|
||||||
// gate arming and tokenization-ready signal arrive out of order.
|
void (async () => {
|
||||||
const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3;
|
const mpvClient = appState.mpvClient;
|
||||||
const releaseRetryDelayMs = 200;
|
if (!mpvClient?.connected) {
|
||||||
const attemptRelease = (attempt: number): void => {
|
logger.debug('[autoplay-ready] skipped unpause fallback; mpv not connected');
|
||||||
void (async () => {
|
return;
|
||||||
if (
|
}
|
||||||
autoPlayReadySignalMediaPath !== mediaPath ||
|
|
||||||
playbackGeneration !== autoPlayReadySignalGeneration
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mpvClient = appState.mpvClient;
|
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||||
if (!mpvClient?.connected) {
|
logger.debug(`[autoplay-ready] mpv paused before fallback for ${mediaPath}: ${shouldUnpause}`);
|
||||||
if (attempt < maxReleaseAttempts) {
|
|
||||||
setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
if (!shouldUnpause) {
|
||||||
|
logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (
|
||||||
|
autoPlayReadySignalMediaPath !== mediaPath ||
|
||||||
|
playbackGeneration !== autoPlayReadySignalGeneration
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
const followupClient = appState.mpvClient;
|
||||||
logger.debug(
|
if (!followupClient?.connected) {
|
||||||
`[autoplay-ready] mpv paused before fallback attempt ${attempt} for ${mediaPath}: ${shouldUnpause}`,
|
return;
|
||||||
);
|
|
||||||
if (!shouldUnpause) {
|
|
||||||
if (attempt === 0) {
|
|
||||||
logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed');
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
signalPluginAutoplayReady();
|
const shouldUnpauseFollowup = await isPlaybackPaused(followupClient);
|
||||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
if (!shouldUnpauseFollowup) {
|
||||||
if (attempt < maxReleaseAttempts) {
|
return;
|
||||||
setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
}
|
||||||
}
|
followupClient.send({ command: ['set_property', 'pause', false] });
|
||||||
})();
|
})();
|
||||||
};
|
}, 500);
|
||||||
attemptRelease(0);
|
logger.debug('[autoplay-ready] issued direct mpv unpause fallback');
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -959,6 +950,7 @@ 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}`);
|
||||||
@@ -1361,23 +1353,6 @@ 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,
|
||||||
@@ -1523,10 +1498,6 @@ 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: {
|
||||||
@@ -2345,7 +2316,9 @@ const {
|
|||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
},
|
},
|
||||||
updateCurrentMediaPath: (path) => {
|
updateCurrentMediaPath: (path) => {
|
||||||
autoPlayReadySignalMediaPath = null;
|
if (appState.currentMediaPath !== path) {
|
||||||
|
autoPlayReadySignalMediaPath = null;
|
||||||
|
}
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
}
|
}
|
||||||
@@ -2451,9 +2424,6 @@ 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]),
|
||||||
@@ -2495,10 +2465,7 @@ const {
|
|||||||
if (startupWarmups.lowPowerMode) {
|
if (startupWarmups.lowPowerMode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!startupWarmups.mecab) {
|
return startupWarmups.mecab;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return shouldInitializeMecabForAnnotations();
|
|
||||||
},
|
},
|
||||||
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
|
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
|
||||||
shouldWarmupSubtitleDictionaries: () => {
|
shouldWarmupSubtitleDictionaries: () => {
|
||||||
@@ -2638,7 +2605,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const synced = await syncYomitanDefaultAnkiServerCore(
|
const updated = await syncYomitanDefaultAnkiServerCore(
|
||||||
targetUrl,
|
targetUrl,
|
||||||
{
|
{
|
||||||
getYomitanExt: () => appState.yomitanExt,
|
getYomitanExt: () => appState.yomitanExt,
|
||||||
@@ -2665,7 +2632,8 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (synced) {
|
if (updated) {
|
||||||
|
logger.info(`Yomitan default profile Anki server set to ${targetUrl}`);
|
||||||
lastSyncedYomitanAnkiServer = targetUrl;
|
lastSyncedYomitanAnkiServer = targetUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2953,30 +2921,6 @@ 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,
|
||||||
@@ -2997,8 +2941,6 @@ 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,7 +180,6 @@ 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'];
|
||||||
@@ -329,7 +328,6 @@ 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,7 +10,6 @@ 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;
|
||||||
@@ -30,8 +29,6 @@ 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,7 +14,6 @@ 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,7 +111,6 @@ 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,14 +22,6 @@ 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;
|
||||||
@@ -244,559 +236,3 @@ 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,58 +133,15 @@ 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 () => {
|
||||||
const warmupTasks: Promise<unknown>[] = [ensureTokenizationPrerequisites()];
|
await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded();
|
||||||
if (
|
if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) {
|
||||||
shouldInitializeMecabForAnnotations() &&
|
await createMecabTokenizerAndCheck().catch(() => {});
|
||||||
!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()
|
|
||||||
) {
|
|
||||||
warmupTasks.push(createMecabTokenizerAndCheck().catch(() => {}));
|
|
||||||
}
|
}
|
||||||
if (shouldWarmupAnnotationDictionaries()) {
|
await prewarmSubtitleDictionaries({ showLoadingOsd: true });
|
||||||
warmupTasks.push(prewarmSubtitleDictionaries().catch(() => {}));
|
|
||||||
}
|
|
||||||
await Promise.all(warmupTasks);
|
|
||||||
tokenizationWarmupCompleted = true;
|
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
tokenizationWarmupInFlight = null;
|
tokenizationWarmupInFlight = null;
|
||||||
});
|
});
|
||||||
@@ -192,21 +149,10 @@ export function composeMpvRuntimeHandlers<
|
|||||||
return tokenizationWarmupInFlight;
|
return tokenizationWarmupInFlight;
|
||||||
};
|
};
|
||||||
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
||||||
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
|
await 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(tokenizerMainDeps),
|
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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,7 +14,6 @@ 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,9 +11,6 @@ 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,
|
||||||
@@ -25,7 +22,6 @@ 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);
|
||||||
@@ -35,7 +31,6 @@ 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,7 +10,6 @@ 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,7 +24,6 @@ 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: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,19 +67,14 @@ 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,8 +86,6 @@ 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,
|
||||||
@@ -105,8 +96,6 @@ 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)')));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +104,6 @@ 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: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +132,6 @@ 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +157,6 @@ 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: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,65 +174,3 @@ 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,12 +17,6 @@ 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,
|
||||||
@@ -31,13 +25,7 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
listJellyfinItems: (
|
listJellyfinItems: (
|
||||||
session: JellyfinSession,
|
session: JellyfinSession,
|
||||||
clientInfo: JellyfinClientInfo,
|
clientInfo: JellyfinClientInfo,
|
||||||
params: {
|
params: { libraryId: string; searchTerm?: string; limit: number },
|
||||||
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,
|
||||||
@@ -56,7 +44,6 @@ 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: {
|
||||||
@@ -67,20 +54,6 @@ 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) {
|
||||||
@@ -106,8 +79,6 @@ 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,10 +31,6 @@ 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');
|
||||||
@@ -48,32 +44,14 @@ 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 () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user