mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d0d2e9c5
|
||
|
|
96c87ae003
|
||
|
|
4842359e39
|
||
|
|
9e53ecb27b
|
||
|
|
e45d302b5d
|
||
|
|
f08d9d1d53
|
||
|
|
01613f1be5 | ||
|
|
bf4e5bb609 | ||
|
|
2aedb3a1fa
|
||
|
|
8809ed38b2
|
||
|
|
7909fcd2cf | ||
|
|
8fc20b2499 | ||
|
|
c18644392d
|
||
|
|
9c351a8603
|
||
|
|
ea2b3a9507 | ||
|
|
6c295586cb
|
||
|
|
0523beef2b
|
||
|
|
a6d96e9966 | ||
|
|
06fc578830
|
||
|
|
7108e086ba | ||
|
|
994483ccd6
|
||
|
|
6350ee928d
|
||
|
|
78a9b6346f | ||
|
|
78eca52b7e | ||
|
|
4d1adebf11
|
||
|
|
e1c4d6ca94
|
||
|
|
e25d472d82
|
||
|
|
20f151482a
|
||
|
|
b48083ddd8 | ||
|
|
4b6842d034 | ||
|
|
170dbbb74c
|
||
|
|
37d3f5e03e
|
||
|
|
55c751830e
|
||
|
|
9ffa2bce94
|
||
|
|
88b73656a7
|
||
|
|
e77aa5c3b3 | ||
|
|
121b688afb | ||
|
|
727be5683a | ||
|
|
138bfe9759 | ||
|
|
0aba17d95b
|
||
|
|
3091d7b2a7
|
||
|
|
4423dd56af
|
||
|
|
a5d0b63792 | ||
|
|
a6df21bdc2
|
||
|
|
e5a6e3b178
|
||
|
|
152d2c0626
|
||
|
|
bbb0e9b6a5
|
||
|
|
e09a416422
|
||
|
|
42f86b0748
|
||
|
|
e69cd5ea06
|
||
|
|
0cff52f8e4
|
||
|
|
b52692d826
|
||
|
|
4ab5e37bc5
|
||
|
|
f00b830054
|
||
|
|
ec73157f37
|
||
|
|
45212c7659
|
||
|
|
b710822db2
|
||
|
|
b347fdf21a
|
||
|
|
a4325740b7
|
||
|
|
4648c5e5a1
|
||
|
|
72d0b19d94
|
||
|
|
c627095e6e
|
||
|
|
dea1136170
|
||
|
|
874d2c9f1a
|
||
|
|
52fc1b63e9
|
||
|
|
da5074761f
|
||
|
|
f7230db90c
|
||
|
|
92ef305e8c
|
||
|
|
504faa51e0
|
||
|
|
c10174f9cd
|
||
|
|
33e694bc61
|
||
|
|
430120cc63
|
||
|
|
294e3a777e
|
||
|
|
c673b71053
|
||
|
|
d31a28c54f
|
||
|
|
d100789232
|
||
|
|
9a5fb02cbd
|
||
|
|
2ba820f41a
|
||
|
|
c39dbb5a8d
|
||
|
|
2958591ec9
|
||
|
|
c9880efee3
|
||
|
|
f225ac3c89
|
||
|
|
cacaa2b371
|
||
|
|
9fe4c7eb33
|
||
|
|
ab5e50adac
|
||
|
|
15c74ef502
|
||
|
|
f0d3c95b4c
|
||
|
|
d7e1d7edc6
|
||
|
|
5354fdf7b9
|
||
|
|
d9fdc7ef6d
|
No files matched your search
@@ -27,6 +27,7 @@ Do not use hidden wrapper commands. Verification commands are owned by `package.
|
||||
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
||||
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
||||
- Stats dashboard: `bun run test:stats`
|
||||
- Overlay, renderer, mpv, or mining behavior in the running app: `bun run build`, then `bun run test:e2e`
|
||||
- Build/release scripts: `bun run test:scripts`
|
||||
|
||||
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
||||
|
||||
@@ -9,3 +9,83 @@ on:
|
||||
jobs:
|
||||
build-test-audit:
|
||||
uses: ./.github/workflows/quality-gate.yml
|
||||
|
||||
# Boots the built app with a real mpv on each platform. Kept outside the
|
||||
# reusable quality gate so releases do not depend on it while the lane is new.
|
||||
e2e:
|
||||
name: E2E (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
SUBMINER_E2E_KEEP: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Point temp files at an uploadable directory
|
||||
run: |
|
||||
# os.tmpdir() follows these, so the kept session logs land under runner.temp.
|
||||
dir="$RUNNER_TEMP/e2e"
|
||||
mkdir -p "$dir"
|
||||
{ echo "TMPDIR=$dir"; echo "TEMP=$dir"; echo "TMP=$dir"; } >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install mpv and ffmpeg (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
# Only Ubuntu sources are needed; unrelated runner repositories can be unavailable.
|
||||
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
|
||||
sudo apt-get "${apt_sources[@]}" update
|
||||
sudo apt-get "${apt_sources[@]}" install -y mpv ffmpeg xvfb
|
||||
|
||||
- name: Install mpv and ffmpeg (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install mpv ffmpeg
|
||||
|
||||
- name: Install mpv and ffmpeg (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
choco install mpv ffmpeg -y --no-progress
|
||||
# The mpv package adds no shim, so put its directory on PATH for later steps.
|
||||
mpv_exe="$(find /c/ProgramData/chocolatey/lib /c/ProgramData/chocolatey/bin "/c/Program Files" /c/tools -maxdepth 6 -iname mpv.exe 2>/dev/null | head -n 1)"
|
||||
test -n "$mpv_exe"
|
||||
cygpath -w "$(dirname "$mpv_exe")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: End-to-end suite
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Dictionary backend windows (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run -a bun run test:dictionary:electron
|
||||
|
||||
- name: Upload session logs (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-logs-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/e2e/subminer-e2e-*/*.log
|
||||
if-no-files-found: ignore
|
||||
@@ -20,6 +20,8 @@ coverage/
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
/build/hachidori/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
[submodule "vendor/subminer-yomitan"]
|
||||
path = vendor/subminer-yomitan
|
||||
url = https://github.com/ksyasuda/subminer-yomitan
|
||||
[submodule "vendor/hachidori"]
|
||||
path = vendor/hachidori
|
||||
url = https://github.com/ksyasuda/hachidori.git
|
||||
branch = subminer
|
||||
@@ -8,6 +8,7 @@ Start here, then leave this file.
|
||||
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
||||
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
||||
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
||||
- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md)
|
||||
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
||||
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
||||
|
||||
@@ -44,10 +45,11 @@ Start here, then leave this file.
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Overlay / renderer / mpv / mining behavior in the running app: `bun run build`, then `bun run test:e2e` (headless, sandboxed; `bun run e2e start` for exploratory checks)
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
|
||||
- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
### Changed
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
### Fixed
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# SubMiner
|
||||
|
||||
Look up words with Yomitan, mine them to Anki, and track your immersion without leaving mpv
|
||||
Look up words with Yomitan or Hachidori, mine them to Anki, and track your immersion without leaving mpv
|
||||
|
||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||
|
||||
@@ -25,6 +25,8 @@ Look up words with Yomitan, mine them to Anki, and track your immersion without
|
||||
|
||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||
|
||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||
</div>
|
||||
@@ -265,7 +267,8 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"": {
|
||||
"name": "subminer",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"axios": "^1.20.0",
|
||||
"commander": "^14.0.3",
|
||||
@@ -21,10 +19,9 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.10.0",
|
||||
"electron": "43.7.7",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.29.1",
|
||||
@@ -127,34 +124,6 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
|
||||
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
||||
|
||||
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
|
||||
@@ -203,16 +172,10 @@
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
@@ -231,13 +194,9 @@
|
||||
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -317,8 +276,6 @@
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -347,7 +304,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.10.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-yBWnZO8tKId0ADvGa4+imPmm3+8Kfs8MtkUPhQQJz1AjTnUUDwCTWkQEU0CrArVVL97Jo4Q/0mVcLHciicBDGQ=="],
|
||||
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||
|
||||
@@ -383,44 +340,16 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
@@ -441,8 +370,6 @@
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
@@ -467,7 +394,7 @@
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
@@ -475,20 +402,12 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -503,9 +422,7 @@
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
@@ -521,12 +438,8 @@
|
||||
|
||||
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
@@ -563,8 +476,6 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
||||
|
||||
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
||||
@@ -581,16 +492,10 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -607,8 +512,6 @@
|
||||
|
||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
|
||||
@@ -625,8 +528,6 @@
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
|
||||
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
||||
@@ -711,8 +612,6 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
@@ -725,8 +624,6 @@
|
||||
|
||||
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -735,8 +632,6 @@
|
||||
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -771,8 +666,6 @@
|
||||
|
||||
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
@@ -787,8 +680,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||
|
||||
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -835,8 +726,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Fixed the AppImage exiting without opening a window when launched inside a sandbox that mounts the image itself, such as `firejail --appimage` (used by the AppImage catalog test). The detached background app now runs straight from the sandbox's squashfs mount instead of trying to re-mount the AppImage over FUSE.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: app
|
||||
|
||||
- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: docs
|
||||
area: docs
|
||||
|
||||
- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
|
||||
- The configuration reference now has a short explanation and a key/default table for each config block.
|
||||
- Fixed docs that no longer matched current behavior.
|
||||
- Fixed the Arch Linux install command for MeCab, which is only in the AUR (`mecab-git`), and brought the README requirements and quick start in line with the installation guide.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: testing
|
||||
|
||||
- Added a headless end-to-end harness (`bun run test:e2e`, `bun run e2e`) that boots the built app with a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect on an offscreen display.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Media timing review now keeps playback paused while it is open when the dictionary popup or subtitle hover pause ends, as with Hachidori popups closing when the review appears. Playback resumes after the review closes unless a dictionary popup is still open.
|
||||
@@ -0,0 +1,15 @@
|
||||
type: added
|
||||
area: dictionary
|
||||
|
||||
- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner.
|
||||
- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` opens Yomitan settings unless a read-only external Yomitan profile is configured. This restriction also applies with Hachidori active, without blocking Hachidori settings.
|
||||
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Character dictionaries reach a linked host over the link (Hachidori 0.2.3 or later, or a current Docker host), retry busy imports, and replace the previous dictionary only after a successful import. Older hosts report where to import the dictionary manually.
|
||||
- Hachidori auto-populates its first Anki template from SubMiner's deck, tags (kept alongside its own `hachidori` tag), and field mappings, detects an unambiguous matching note type, and preserves existing custom templates apart from the deck, which follows `ankiConnect.deck` so polling mode enriches Hachidori cards. Anki discovery retries after an unavailable connection.
|
||||
- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay.
|
||||
- Both bundled dictionary backends reload their background code on startup so extension updates take effect while preserving installed dictionaries and settings. Failed Yomitan connection-setting updates can be retried without manually resetting the managed Anki endpoint.
|
||||
- First-run setup remembers each backend that finished it, including when reopened for legacy plugin cleanup, so switching back does not repeat setup, and the launcher gates playback on the backend the running app started with. Current incomplete or cancelled setup takes precedence over stale completion history. A running Yomitan session keeps using its external profile until it restarts into Hachidori.
|
||||
- Stats dashboard mining and deck lookup use the selected backend, with the Anki proxy enabled or disabled. Dashboard cards retain the selected history line and media instead of being processed again with the current mpv subtitle, with both Yomitan and Hachidori. Stats cards carry a `SubMiner::Stats` tag so polling also preserves their context. Hachidori word mining supplies dictionary aliases, IDs, and frequency metadata to its native Anki renderer. Settings labels for popup pause and the dictionary deck no longer name Yomitan, and the backend selector sits with the other dictionary settings.
|
||||
- Hachidori scans, dictionary counts, and settings reads wait for the dictionary engine to finish loading or importing instead of caching empty results.
|
||||
- Character dictionary imports report completion as soon as the archive is read instead of waiting out the temporary server's keep-alive timeout.
|
||||
- First-run setup can link an external Hachidori dictionary host in an app, browser, or Docker container, verify its library, and unlink back to local dictionaries. The optional host controls are collapsed by default and explain which apps or containers must stay running. Unresponsive connection checks time out so setup remains usable. Anki mining, media enrichment, and custom toolbar buttons stay in SubMiner, including when editing buttons while linked.
|
||||
- Hachidori frequency highlighting reuses dictionary-entry ranks and fills missing ranks through its existing API. Entries without a matching definition may remain unranked. The bundled integration is maintained in a pinned fork submodule using upstream HoshiDicts and WASM binaries.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: dictionary
|
||||
|
||||
- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay (subtitles and sidebar) becoming unclickable after mpv goes fullscreen on Hyprland with `mpv.backend: x11`. The launcher no longer strips `HYPRLAND_INSTANCE_SIGNATURE` from the X11 environment, so the overlay can lift Hyprland's fullscreen input block again.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Fixed `subminer jellyfin -p` reporting "No Jellyfin libraries found." when SubMiner was already running (for example right after `subminer jellyfin` login). The app now hands library, item, and preview-auth results to the launcher directly instead of through log files, so picker image previews work again and real errors (expired session, unreachable server) are shown instead of an empty list.
|
||||
- The rofi "Jellyfin Search (optional)" step after picking a library is now a compact input box with a "Type to search, or press Enter to browse all" hint instead of a blank list.
|
||||
- Playing from the terminal (`subminer jellyfin -p`) now reports watch progress to Jellyfin even when cast discovery is off, so Jellyfin remembers where you stopped and marks finished episodes as played. Previously progress was only sent while the cast connection was active.
|
||||
- When `subminer jellyfin -p` has to start SubMiner itself, the app now shows its tray icon and quits when you close mpv, the same as playing a local file. Before, it had no tray icon and kept running hidden after playback. A SubMiner that was already running is still reused and left running.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
|
||||
- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Timing review previews and mined card audio now use the audio track you are playing on Jellyfin direct-play streams, instead of the track Jellyfin marks as default.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: jellyfin
|
||||
|
||||
- Corrected the Jellyfin docs: SubMiner only joins the cast menu at startup when `startupWarmups.jellyfinRemoteSession` is on (off by default) along with `jellyfin.remoteControlAutoConnect`. Otherwise tick **Jellyfin Discovery** in the tray.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Japanese subtitle generation now works while playing a Jellyfin episode. SubMiner reads the original file from the server, saves the subtitles to a cache folder, and loads them into mpv. Direct-play and transcoded playback are both supported.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keyboard-only mode no longer swallows `h`, `l`, and the left/right arrows while a modal is open or a text field has focus, so typing into fields like the Jimaku or TsukiHime title works and no longer seeks mpv. Pressing `Ctrl/Cmd+Shift+Y` (or the controller toggle) inside a modal no longer turns on a hidden keyboard-only mode in the modal window.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- Fixed the command-line launcher showing "Not on PATH" on macOS when SubMiner was opened from Finder or the Dock, even though the launcher directory was already on your terminal PATH.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
|
||||
- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: dependencies
|
||||
|
||||
- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: annotations
|
||||
|
||||
- Subtitle lines shown while two cues overlap (common in Jimaku and Jellyfin SRTs, and usually the long lines) are now prefetched, so their annotations appear immediately instead of after a delay.
|
||||
- Subtitle prefetching no longer restarts each time the same subtitle file is re-selected during startup, and a line tokenized before a restart or seek is kept instead of discarded.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: launcher
|
||||
|
||||
- Removed `SUBMINER_JIMAKU_API_KEY`, `SUBMINER_JIMAKU_API_KEY_COMMAND`, and `SUBMINER_JIMAKU_API_BASE_URL` from the development docs' environment variable table. The launcher never used them; set the Jimaku key with `jimaku.apiKey` / `jimaku.apiKeyCommand` in the config instead.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: cleanup
|
||||
|
||||
- Removed dead code: the unused launcher Jimaku client (the `SUBMINER_JIMAKU_*` env vars it read were documented but had no effect), the mpv plugin's hover-highlight module (no sender since Overlay 2.0; custom mpv scripts that still send `subminer-hover-token` or `yomipv-hover-token` are now ignored), the never-read `anilist.characterDictionary.refreshTtlHours` / `evictionPolicy` config keys, unused root dependencies (eslint, geist fonts), and a committed draft plan doc.
|
||||
- Typecheck now runs with `noUnusedLocals`; removed the unused locals, imports, and the Windows overlay focus-handoff values they fed, which were computed but never used.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: overlay
|
||||
|
||||
- Removed 16 overlay preload/IPC methods that nothing called, plus their main-process handlers. SubMiner no longer observes mpv's `sub-text/ass` or sends every ASS line to overlay windows that never listened.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- First-run setup now labels a missing command-line launcher "Not set up" instead of "Not installed", so the AppImage catalog test no longer reads the setup window as an error screen.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Subtitle lines recorded before halfwidth katakana widening now show fullwidth katakana in the stats dashboard (word/kanji occurrences, sentence search), and cards mined from stats history get the fullwidth sentence and word.
|
||||
- Sentence search matches katakana in either width, so searching アンタ also finds older lines stored as アンタ.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS with Bluetooth headphones such as AirPods, the timing review preview cursor now moves smoothly across the selection instead of stuttering and jumping to the end partway through. This affected Opus audio, including Jellyfin streams.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
|
||||
- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up.
|
||||
- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Halfwidth katakana in subtitles (common in broadcast-derived Japanese subs, e.g. アンタ, ダキニ) now displays as normal fullwidth katakana, so lines no longer mix squashed and normal-width text. Mined sentences and stats get the fullwidth form too.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: added
|
||||
area: youtube
|
||||
|
||||
- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
|
||||
- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
|
||||
- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
|
||||
- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: added
|
||||
area: youtube
|
||||
|
||||
- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
|
||||
- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
|
||||
- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
@@ -15,9 +15,9 @@ If you changed AnkiConnect's port, set `ankiConnect.url` to match.
|
||||
|
||||
## How cards get filled
|
||||
|
||||
When Yomitan adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
When Yomitan or Hachidori adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. Yomitan sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. The dictionary sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds.
|
||||
|
||||
Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead.
|
||||
@@ -42,6 +42,20 @@ Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to Anki
|
||||
|
||||
- **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`.
|
||||
- **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine.
|
||||
- **Hachidori.** SubMiner routes Hachidori to the proxy while it is active. Keep the proxy on for screenshots and sentence audio.
|
||||
|
||||
### Hachidori settings from SubMiner
|
||||
|
||||
With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings:
|
||||
|
||||
- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck.
|
||||
- While the template still has Hachidori's default `hachidori` tag, the configured tags are added ahead of it so cards carry both. Tags you edit in Hachidori settings are kept as written.
|
||||
- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set.
|
||||
- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields.
|
||||
|
||||
Apart from the deck, SubMiner only fills missing settings. Custom tags, field mappings, advanced templates, and extra templates stay as you set them. If several note types match, pick one in Hachidori Settings. If Anki was closed, start it and open Hachidori Settings again to retry.
|
||||
|
||||
Sentence audio, image timing, translation, metadata, and field grouping stay under SubMiner's control. Pronunciation sources are set in Hachidori. Linking an external dictionary host does not change any of this.
|
||||
|
||||
### Proxy troubleshooting
|
||||
|
||||
@@ -87,6 +101,8 @@ If cards are not getting filled:
|
||||
|
||||
Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped.
|
||||
|
||||
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
|
||||
|
||||
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
|
||||
|
||||
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
|
||||
@@ -122,6 +138,8 @@ Media settings apply to the next card without a restart.
|
||||
|
||||
With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
|
||||
|
||||
Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused.
|
||||
|
||||
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
|
||||
|
||||
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
|
||||
@@ -208,6 +226,8 @@ For Senren, use the same keys under `isSenren`.
|
||||
| `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card |
|
||||
| `manual` | Show both cards, let you choose which to keep and preview the merge |
|
||||
|
||||
Grouping runs when a new note is added while duplicates exist. With Hachidori, that is the popup's **Add anyway** choice. **Overwrite** updates the existing note in place and only gets media.
|
||||
|
||||
The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself.
|
||||
|
||||
| Key | Action |
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
**Added**
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
**Changed**
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
**Fixed**
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
**Docs**
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -67,6 +67,12 @@ Everything else needs a restart.
|
||||
|
||||
## Core settings
|
||||
|
||||
### Dictionary backend
|
||||
|
||||
`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection.
|
||||
|
||||
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
|
||||
|
||||
### Logging
|
||||
|
||||
Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone.
|
||||
@@ -515,12 +521,12 @@ In external-profile mode, SubMiner only reads the profile. It does not open its
|
||||
Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ----------------------------------- | -------------------------------- | ----------------------------------------------- |
|
||||
| ----------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `jellyfin.enabled` | `false` | Enable Jellyfin |
|
||||
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
|
||||
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
|
||||
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
|
||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup |
|
||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup (also needs `startupWarmups.jellyfinRemoteSession`) |
|
||||
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
|
||||
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
|
||||
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
|
||||
|
||||
@@ -47,6 +47,10 @@ electron . --start --dev --log-level debug # verbose launch of an existing bui
|
||||
electron . --background # tray/background mode
|
||||
```
|
||||
|
||||
Development and debug launches use a separate `SubMiner-dev` profile so runtime experiments cannot modify the installed app's configuration or Yomitan dictionaries. To intentionally use the production profile for a development launch, set `SUBMINER_USE_PRODUCTION_PROFILE=1`. Use that override only after backing up the profile.
|
||||
|
||||
Launch source builds through `bun run dev` or `bun run electron`. SubMiner refuses to load its profile when the running Electron major differs from the version pinned by the repository.
|
||||
|
||||
To test through the mpv plugin without exporting `SUBMINER_BINARY_PATH` each time, point `mpv.subminerBinaryPath` in your config at the dev script. The launcher passes it to the plugin at runtime:
|
||||
|
||||
```json
|
||||
@@ -171,9 +175,6 @@ Run `make help` for the full list.
|
||||
| `SUBMINER_MPV_PLUGIN_PATH` | mpv plugin directory the launcher injects |
|
||||
| `SUBMINER_LOG_LEVEL` | App log level (`debug`, `info`, `warn`, `error`) |
|
||||
| `SUBMINER_MPV_LOG` | Shared mpv/app log file path |
|
||||
| `SUBMINER_JIMAKU_API_KEY` | Jimaku API key for launcher subtitle downloads |
|
||||
| `SUBMINER_JIMAKU_API_KEY_COMMAND` | Command that prints the Jimaku API key |
|
||||
| `SUBMINER_JIMAKU_API_BASE_URL` | Jimaku API base URL |
|
||||
| `SUBMINER_JELLYFIN_ACCESS_TOKEN` | Jellyfin access token, used before the stored encrypted session |
|
||||
| `SUBMINER_JELLYFIN_USER_ID` | Jellyfin user ID |
|
||||
| `SUBMINER_SKIP_MACOS_HELPER_BUILD` | Set to `1` to skip the macOS helper build during `bun run build` |
|
||||
@@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
|
||||
|
||||
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
||||
|
||||
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
|
||||
|
||||

|
||||
|
||||
### Library
|
||||
@@ -89,11 +91,13 @@ Searches the primary subtitle lines and titles in your history. **Search by head
|
||||
|
||||
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available:
|
||||
|
||||
- **Mine Word**: full Yomitan lookup for the word, plus sentence, audio, and image.
|
||||
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
|
||||
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
|
||||
- **Mine Audio**: an audio card with `IsAudioCard` set.
|
||||
|
||||
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs Yomitan dictionaries.
|
||||
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs dictionaries in the selected backend.
|
||||
|
||||
Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, SubMiner uses it to keep the chosen history line when polling picks up the card.
|
||||
|
||||
## Repeated lines
|
||||
|
||||
@@ -122,4 +126,6 @@ Leave out `--lookback-days` to scan all history. Word and kanji counts are corre
|
||||
|
||||
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
|
||||
|
||||
Deleting retained sessions preserves earlier word and kanji discovery dates from pruned history. New occurrences record observation time so deletion can update their dates exactly. Older history used write time, so an earlier discovery date is preserved when its origin cannot be determined.
|
||||
|
||||
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
|
||||
@@ -14,7 +14,7 @@ SubMiner stores an encrypted session token, not your password, and turns the int
|
||||
|
||||
## Casting from Jellyfin
|
||||
|
||||
After you sign in, SubMiner connects to Jellyfin at startup and shows up in the cast ("Play on") menu under your computer's hostname. To connect for the current session only, tick **Jellyfin Discovery** in the tray menu.
|
||||
After you sign in, tick **Jellyfin Discovery** in the tray menu. SubMiner then shows up in the cast ("Play on") menu under your computer's hostname until it quits. To connect every time SubMiner starts, turn on `startupWarmups.jellyfinRemoteSession` and leave `remoteControlAutoConnect` on.
|
||||
|
||||
1. In the Jellyfin web or mobile app, start playing an episode.
|
||||
2. Open the cast menu and pick your computer.
|
||||
@@ -23,6 +23,8 @@ SubMiner starts mpv if it is not already running. Pause, seek, stop, and track c
|
||||
|
||||
SubMiner selects a Japanese subtitle track automatically and resets mpv's subtitle delay to zero. It direct-plays files when it can and asks Jellyfin to transcode the rest.
|
||||
|
||||
If an episode has no Japanese subtitles, you can [generate them with Whisper](/subtitle-generation#generating-from-the-overlay) while it plays.
|
||||
|
||||
On Windows, casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid `mpv.executablePath` stops mpv from starting.
|
||||
|
||||
## Playing from the terminal
|
||||
@@ -34,18 +36,18 @@ subminer jellyfin -p # fzf picker; `jf` is an alias for `jellyfin`
|
||||
subminer -R jellyfin -p # rofi picker
|
||||
```
|
||||
|
||||
Sign in first. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||
Sign in first. Playback resumes from Jellyfin's saved position, and watch progress syncs back the same way it does for casting, whether or not discovery is on. If SubMiner is already running, the picker uses it and leaves it running. Otherwise the launcher starts SubMiner and quits it when you close mpv, the same as playing a local file. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||
|
||||
## Options
|
||||
|
||||
All options are under **Settings > Integrations > Jellyfin**, or `jellyfin` in `config.jsonc`. See [Configuration](/configuration#jellyfin) for the full list and defaults.
|
||||
|
||||
| Key | What it does |
|
||||
| -------------------------- | -------------------------------------------------------------------- |
|
||||
| -------------------------- | ---------------------------------------------------------------------- |
|
||||
| `enabled` | Turns the integration on. Set for you when you sign in. |
|
||||
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
|
||||
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
|
||||
| `remoteControlAutoConnect` | Connects at startup. Turn off to start discovery from the tray. |
|
||||
| `remoteControlAutoConnect` | Connects at startup when `startupWarmups.jellyfinRemoteSession` is on. |
|
||||
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
|
||||
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
@@ -7,11 +7,11 @@ Shortcuts work when the overlay has focus. With the [mpv plugin](/mpv-plugin), `
|
||||
## Global
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------- | ---------------------- | -------------------------------------- |
|
||||
| ------------- | ------------------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | Fixed |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | Fixed |
|
||||
|
||||
`Alt+Shift+Y` is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
|
||||
`Alt+Shift+Y` opens Yomitan or Hachidori settings, whichever backend is running. It is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
|
||||
|
||||
## Mining
|
||||
|
||||
@@ -99,12 +99,12 @@ The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Be
|
||||
Press `y`, then the second key.
|
||||
|
||||
| Keys | Action |
|
||||
| ----- | -------------------------- |
|
||||
| ----- | ------------------------------- |
|
||||
| `y-y` | Open the SubMiner menu |
|
||||
| `y-s` | Start the overlay |
|
||||
| `y-S` | Stop the overlay |
|
||||
| `y-t` | Toggle the visible overlay |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-o` | Open active dictionary settings |
|
||||
| `y-r` | Restart the overlay |
|
||||
| `y-c` | Show overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
@@ -16,7 +16,7 @@ The modal's **Local tools** section lists anything missing. After you install a
|
||||
|
||||
## Generating from the overlay
|
||||
|
||||
1. Open a local video in mpv and select its Japanese audio track, or play a YouTube video.
|
||||
1. Open a local video in mpv and select its Japanese audio track, or play a Jellyfin or YouTube video.
|
||||
2. Press `Ctrl+Shift+G`. If the subtitle sidebar is empty, its **Generate Japanese subtitles** button opens the same modal.
|
||||
3. Pick a model and download it if needed.
|
||||
4. Optionally check **Focus on spoken dialogue** (see below).
|
||||
@@ -26,6 +26,8 @@ The modal shows progress. **Cancel** stops the job. Closing the modal lets the j
|
||||
|
||||
SubMiner saves `<video>.ja.generated.srt` next to the video and adds a number if that name is taken. If the same file is still playing, it loads the subtitles and resets the subtitle delay.
|
||||
|
||||
For a Jellyfin video, SubMiner reads the original file from your server and saves the subtitles to `~/.cache/subminer/generated-subs/`, since a stream has no folder of its own. Each Jellyfin generation replaces the previous file there. Playback keeps going while it works. If Jellyfin is transcoding, SubMiner transcribes the audio track that playback started with.
|
||||
|
||||
For a YouTube video, SubMiner downloads its audio first and pauses the video until the subtitles load. The subtitles are temporary, and the audio is deleted when generation ends or you switch videos. See [YouTube integration](/youtube-integration#generate-subtitles-with-whisper).
|
||||
|
||||
Change the shortcut with `shortcuts.openSubtitleGeneration`.
|
||||
@@ -46,7 +48,7 @@ subminer generate-subs episode.mkv --model-path /path/to/ggml-small.bin
|
||||
| `--audio-stream <index>` | Pick an audio stream by its absolute FFmpeg index |
|
||||
| `--output <path>` | Write to this SRT path. Existing files are never overwritten. |
|
||||
|
||||
With a file argument, SubMiner uses the audio stream tagged Japanese, or the first stream. `Ctrl+C` cancels.
|
||||
With a file argument, SubMiner uses the audio stream tagged Japanese, or the first stream. `Ctrl+C` cancels. The launcher only works with local files. Use the overlay for Jellyfin and YouTube.
|
||||
|
||||
## Choosing a model
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@ If you have not set up dictionaries yet, start with [Yomitan setup](/usage#yomit
|
||||
|
||||
Word boundaries come from Yomitan's parser. Some splits will be wrong, since Japanese has no spaces.
|
||||
|
||||
## "Electron downgrade blocked" or "Unsupported Electron runtime"
|
||||
|
||||
SubMiner refuses to load Yomitan storage when the current Electron major does not match the app build, or when the profile was previously opened by a newer Electron major or minor version. Patch downgrades within the same major and minor version are allowed, such as `43.7.3` to `43.7.2`. Downgrades from `43.7.x` to `43.6.x`, or from Electron 43 to 42, are blocked. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record to force an older major or minor version to open the profile.
|
||||
|
||||
Older SubMiner builds without this guard cannot enforce the safety record. When testing Electron 43 before returning to an older Electron 42 build, use the separate `SubMiner-dev` profile described in [development](/development#run-locally) so the older build never opens the upgraded profile.
|
||||
|
||||
## "Yomitan reported zero dictionaries after previously reporting ..."
|
||||
|
||||
SubMiner detected that a previously non-empty Yomitan profile suddenly appears empty. Automatic character-dictionary changes are blocked so they cannot overwrite the suspicious state. Close SubMiner, preserve the profile directory, and restore a known-good backup before importing or deleting dictionaries.
|
||||
|
||||
## "Yomitan extension not found in any search path"
|
||||
|
||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
||||
|
||||
+39
-4
@@ -16,10 +16,41 @@ Run `subminer` with no file to pick one from the current directory instead. See
|
||||
|
||||
### Yomitan setup
|
||||
|
||||
Lookups need at least one dictionary in SubMiner's bundled Yomitan. First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
|
||||
Lookups need at least one dictionary in SubMiner's bundled Yomitan (or in Hachidori, if you [switched backends](#hachidori-setup)). First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
|
||||
|
||||
The bundled Yomitan is separate from any Yomitan in your browser. It has its own dictionaries and settings.
|
||||
|
||||
### Hachidori setup
|
||||
|
||||
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||
|
||||
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
||||
|
||||
Hachidori uses SubMiner's subtitle scanning, popup pause, controller commands, character dictionaries, and Anki media. Keep the [Anki proxy](/anki-integration#proxy-mode-setup-yomitan-texthooker) on for screenshots and sentence audio. Hachidori's own screen recorder and screenshot capture are off inside SubMiner. `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to whichever backend is selected.
|
||||
|
||||
Switching backends:
|
||||
|
||||
- First-run setup asks for dictionaries the first time you switch to a backend. Switching back to a backend that already finished setup skips it.
|
||||
- Until you restart, SubMiner keeps running the backend it started with. The launcher waits for that backend before playback and logs a restart reminder.
|
||||
- `--yomitan` and `--hachidori` both work whichever backend is selected. Opening settings does not switch backends.
|
||||
- When `yomitan.externalProfilePath` is set, `--yomitan` is disabled to keep the external profile read-only. Hachidori settings still open.
|
||||
|
||||
#### External dictionary host
|
||||
|
||||
First-run setup can link a Hachidori host instead of using local dictionaries: **Dictionary source → Use an external dictionary host → Link host**. Turn on sharing in the other Hachidori app or browser, or start a compatible Docker host, then enter its sharing address, for example `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port.
|
||||
|
||||
| Host | Must be running |
|
||||
| -------- | ------------------------------------------- |
|
||||
| Browser | The browser, the Hachidori extension, relay |
|
||||
| Electron | The host app and any relay it needs |
|
||||
| Docker | The container only |
|
||||
|
||||
Setup checks the connection and the host's dictionaries before **Finish** unlocks, so import at least one dictionary on the host and refresh. The link survives restarts. **Unlink and use local dictionaries** goes back to local.
|
||||
|
||||
While linked, dictionaries and dictionary settings come from the host. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio and image processing stay local. Frequency annotations use ranks returned with dictionary entries, and SubMiner asks the host for missing ones. Words with no matching definition entry may stay unranked even if a frequency dictionary lists them.
|
||||
|
||||
SubMiner sends [character dictionaries](/character-dictionary) to the linked host over the link and replaces the previous one once the import succeeds, retrying while the host is busy. The host needs Hachidori 0.2.3 or later, or a current Docker host. With an older host, sync reports where the merged ZIP is so you can import it from that host's Hachidori settings.
|
||||
|
||||
## Picking files
|
||||
|
||||
```bash
|
||||
@@ -34,14 +65,14 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
|
||||
## Overlay basics
|
||||
|
||||
| Key | Action |
|
||||
| ------------- | --------------------------------------------------------------------- |
|
||||
| ------------- | ---------------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings (works from any window, not configurable) |
|
||||
| `Alt+Shift+Y` | Open Yomitan or Hachidori settings (works from any window, not configurable) |
|
||||
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
|
||||
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
|
||||
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
|
||||
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open Yomitan popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
|
||||
You can drop files onto the overlay:
|
||||
|
||||
@@ -145,6 +176,10 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear
|
||||
|
||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
|
||||
|
||||
## Development profiles
|
||||
|
||||
`--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set. See [Development](/development#run-locally).
|
||||
|
||||
## Changing settings while you watch
|
||||
|
||||
SubMiner watches your config file and applies most changes without a restart, including subtitle style, keybindings, and most Anki settings. If a change needs a restart, SubMiner tells you. If the file has an error, it keeps the last working config and shows a notification. See [Configuration](/configuration).
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
# Renderer Performance Optimizations
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Draft
|
||||
|
||||
## Goal
|
||||
|
||||
Minimize the time between a subtitle line appearing and annotations being displayed. Three optimizations target different pipeline stages to achieve this.
|
||||
|
||||
## Current Pipeline (Warm State)
|
||||
|
||||
```text
|
||||
MPV subtitle change (0ms)
|
||||
-> IPC to main (5ms)
|
||||
-> Cache check (2ms)
|
||||
-> [CACHE MISS] Yomitan parser (35-180ms)
|
||||
-> Parallel: MeCab enrichment (20-80ms) + Frequency lookup (15-50ms)
|
||||
-> Annotation stage: 4 sequential passes (25-70ms)
|
||||
-> IPC to renderer (10ms)
|
||||
-> DOM render: createElement per token (15-50ms)
|
||||
─────────────────────────────────
|
||||
Total: ~200-320ms (cache miss)
|
||||
Total: ~72ms (cache hit)
|
||||
```
|
||||
|
||||
## Target Pipeline
|
||||
|
||||
```text
|
||||
MPV subtitle change (0ms)
|
||||
-> IPC to main (5ms)
|
||||
-> Cache check (2ms)
|
||||
-> [CACHE HIT via prefetch] (0ms)
|
||||
-> IPC to renderer (10ms)
|
||||
-> DOM render: cloneNode from template (10-30ms)
|
||||
─────────────────────────────────
|
||||
Total: ~30-50ms (prefetch-warmed, normal playback)
|
||||
|
||||
[CACHE MISS, e.g. immediate seek]
|
||||
-> Yomitan parser (35-180ms)
|
||||
-> Parallel: MeCab enrichment + Frequency lookup
|
||||
-> Annotation stage: 1 batched pass (10-25ms)
|
||||
-> IPC to renderer (10ms)
|
||||
-> DOM render: cloneNode from template (10-30ms)
|
||||
─────────────────────────────────
|
||||
Total: ~150-260ms (cache miss, still improved)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optimization 1: Subtitle Prefetching
|
||||
|
||||
### Summary
|
||||
|
||||
A new `SubtitlePrefetchService` parses external subtitle files and tokenizes upcoming lines in the background before they appear on screen. This converts most cache misses into cache hits during normal playback.
|
||||
|
||||
### Scope
|
||||
|
||||
External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out of scope since Japanese subtitles are virtually always external files.
|
||||
|
||||
### Architecture
|
||||
|
||||
#### Subtitle File Parsing
|
||||
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
|
||||
```typescript
|
||||
interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // plain text, decoded from the source format
|
||||
source?: 'canonical-ass'; // recovered authored text for generated ASS animation
|
||||
animationStartTime?: number; // full generated-frame envelope; entrance/exit frames
|
||||
animationEndTime?: number; // run past the authored timing, live matching uses this
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas.
|
||||
|
||||
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||
|
||||
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||
|
||||
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
|
||||
|
||||
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible.
|
||||
|
||||
**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it.
|
||||
|
||||
#### Prefetch Service Lifecycle
|
||||
|
||||
1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure.
|
||||
2. **Parse phase:** Parse all cues from the file content. Sort by start time. Store as an ordered array.
|
||||
3. **Priority window:** Determine the current playback position. Identify the next 10 cues as the priority window.
|
||||
4. **Priority tokenization:** Tokenize the priority window cues sequentially, storing results into the `SubtitleProcessingController`'s tokenization cache.
|
||||
5. **Background tokenization:** After the priority window is done, tokenize remaining cues working forward from the current position, then wrapping around to cover earlier cues. The prefetcher stops once it has tokenized all cues or the cache is full (whichever comes first) to avoid wasteful eviction churn. For files with more cues than the cache limit, background tokenization focuses on cues ahead of the current position.
|
||||
6. **Seek handling:** On seek, re-compute the priority window from the new position. A seek is detected by observing MPV's `time-pos` property and checking if the delta from the last observed position exceeds a threshold (e.g., > 3 seconds forward or any backward jump). The current in-flight tokenization finishes naturally, then the new priority window takes over.
|
||||
7. **Teardown:** When the subtitle track changes or playback ends, stop all prefetch work and discard state.
|
||||
|
||||
#### Live Priority
|
||||
|
||||
The prefetcher and live subtitle handler share the Yomitan parser (single-threaded IPC). Live subtitle requests must always take priority. The prefetcher:
|
||||
|
||||
- Checks a `paused` flag before each cue tokenization. The live handler sets `paused = true` on subtitle change and clears it after emission.
|
||||
- Yields between each background cue tokenization (via `setTimeout(0)` or equivalent) so the live handler can set the pause flag between cues.
|
||||
- When paused, the prefetcher waits (polling the flag on a short interval or awaiting a resume signal) before continuing with the next cue.
|
||||
|
||||
#### Cache Integration
|
||||
|
||||
The prefetcher calls the same `tokenizeSubtitle` function used by live processing to produce `SubtitleData` results, then stores them into the existing `SubtitleProcessingController` tokenization cache via a new method:
|
||||
|
||||
```typescript
|
||||
// New methods on SubtitleProcessingController
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
isCacheFull: () => boolean;
|
||||
```
|
||||
|
||||
`preCacheTokenization` uses the same `setCachedTokenization` logic internally (LRU eviction, Map-based storage). `isCacheFull` returns `true` when the cache has reached its limit, allowing the prefetcher to stop background tokenization and avoid wasteful eviction churn.
|
||||
|
||||
#### Cache Invalidation
|
||||
|
||||
When the user marks a word as known (or any event triggers `invalidateTokenizationCache()`), all cached results are cleared -- including prefetched ones, since they share the same cache. After invalidation, the prefetcher re-computes the priority window from the current playback position and re-tokenizes those cues to restore warm cache state.
|
||||
|
||||
#### Error Handling
|
||||
|
||||
If the subtitle file is malformed or partially parseable, the cue parser uses what it can extract. A file that yields zero cues disables prefetching silently (falls back to live-only processing). Encoding errors from `loadSubtitleSourceText` are caught and logged; prefetching is skipped for that track.
|
||||
|
||||
#### Integration Points
|
||||
|
||||
- **MPV property subscriptions:** Needs `track-list` (to detect external subtitle file path) and `time-pos` (to track playback position for window calculation and seek detection).
|
||||
- **File loading:** Uses existing `loadSubtitleSourceText` dependency.
|
||||
- **Tokenization:** Calls the same `tokenizeSubtitle` function used by live processing.
|
||||
- **Cache:** Writes into `SubtitleProcessingController`'s cache.
|
||||
- **Cache invalidation:** Listens for cache invalidation events to re-prefetch the priority window.
|
||||
|
||||
### Files Affected
|
||||
|
||||
- **New:** `src/core/services/subtitle-prefetch.ts` -- the prefetch service
|
||||
- **New:** `src/core/services/subtitle-cue-parser.ts` -- SRT/VTT/ASS cue parser (text + timing)
|
||||
- **Modified:** `src/core/services/subtitle-processing-controller.ts` -- expose `preCacheTokenization` method
|
||||
- **Modified:** `src/main.ts` -- wire up the prefetch service, listen to track changes
|
||||
|
||||
---
|
||||
|
||||
## Optimization 2: Batched Annotation Pass
|
||||
|
||||
### Summary
|
||||
|
||||
Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFrequencyMarking` -> `applyJlptMarking` -> `markNPlusOneTargets`) into a single iteration over the token array, followed by N+1 marking.
|
||||
|
||||
**Important context:** Frequency rank _values_ (`token.frequencyRank`) are already assigned at the parser level by `applyFrequencyRanks()` in `tokenizer.ts`, before the annotation stage is called. The annotation stage's `applyFrequencyMarking` only performs POS-based _filtering_ -- clearing `frequencyRank` to `undefined` for tokens that should be excluded (particles, noise tokens, etc.) and normalizing valid ranks. This optimization does not change the parser-level frequency rank assignment; it only batches the annotation-level filtering.
|
||||
|
||||
### Current Flow (4 passes, 4 array copies)
|
||||
|
||||
```text
|
||||
tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||
-> applyKnownWordMarking() // .map() -> new array
|
||||
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)
|
||||
-> applyJlptMarking() // .map() -> new array
|
||||
-> markNPlusOneTargets() // .map() -> new array
|
||||
```
|
||||
|
||||
### Dependency Analysis
|
||||
|
||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||
|
||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||
- **N+1 marking:** Uses POS exclusion sets to filter candidates. Depends on known word status + MeCab POS.
|
||||
|
||||
Since frequency filtering and JLPT marking both depend on POS data from MeCab enrichment, and MeCab enrichment already happens before the annotation stage, all four can run in a single pass after MeCab completes.
|
||||
|
||||
### New Flow (1 pass + N+1)
|
||||
|
||||
```typescript
|
||||
function annotateTokens(tokens, deps, options): MergedToken[] {
|
||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||
|
||||
// Single pass: known word + frequency filtering + JLPT computed together
|
||||
const annotated = tokens.map((token) => {
|
||||
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||
|
||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||
const frequencyRank = frequencyEnabled
|
||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||
|
||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||
});
|
||||
|
||||
// N+1 must run after known word status is set for all tokens
|
||||
if (nPlusOneEnabled) {
|
||||
return markNPlusOneTargets(annotated, minSentenceWords, pos1Exclusions, pos2Exclusions);
|
||||
}
|
||||
|
||||
return annotated;
|
||||
}
|
||||
```
|
||||
|
||||
### What Changes
|
||||
|
||||
- The individual `applyKnownWordMarking`, `applyFrequencyMarking`, `applyJlptMarking` functions are refactored into per-token computation helpers (pure functions that compute a single field). The frequency helper is named `filterFrequencyRank` to clarify it performs POS-based exclusion, not rank computation.
|
||||
- The `annotateTokens` orchestrator runs one `.map()` call that invokes all three helpers per token.
|
||||
- `markNPlusOneTargets` remains a separate pass because it needs the full array with `isKnown` set (it examines sentence-level context).
|
||||
- The parser-level `applyFrequencyRanks()` call in `tokenizer.ts` is unchanged -- it remains a separate step outside the annotation stage.
|
||||
- Net: 4 array copies + 4 iterations become 1 array copy + 1 iteration + N+1 pass.
|
||||
|
||||
### Expected Savings
|
||||
|
||||
~15-45ms saved (3 fewer array allocations + 3 fewer full iterations). Annotation drops from ~25-70ms to ~10-25ms.
|
||||
|
||||
### Files Affected
|
||||
|
||||
- **Modified:** `src/core/services/tokenizer/annotation-stage.ts` -- refactor into batched single-pass
|
||||
|
||||
---
|
||||
|
||||
## Optimization 3: DOM Template Pooling
|
||||
|
||||
### Summary
|
||||
|
||||
Replace `document.createElement('span')` calls in the renderer with `templateSpan.cloneNode(false)` from a pre-created template element.
|
||||
|
||||
### Current Behavior
|
||||
|
||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||
|
||||
1. Clears DOM with `innerHTML = ''`
|
||||
2. Creates a `DocumentFragment`
|
||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||
4. Sets `className`, `textContent`, `dataset.*` individually
|
||||
5. Appends fragment to root
|
||||
|
||||
### New Behavior
|
||||
|
||||
1. At renderer initialization (`createSubtitleRenderer`), create a single template:
|
||||
```typescript
|
||||
const templateSpan = document.createElement('span');
|
||||
```
|
||||
2. In `renderWithTokens`, replace every `document.createElement('span')` with:
|
||||
```typescript
|
||||
const span = templateSpan.cloneNode(false) as HTMLSpanElement;
|
||||
```
|
||||
3. Replace all `innerHTML = ''` calls with `root.replaceChildren()` to avoid the HTML parser invocation on clear. This applies to `renderSubtitle` (primary subtitle root), `renderSecondarySub` (secondary subtitle root), and `renderCharacterLevel` if applicable.
|
||||
4. Everything else stays the same (setting className, textContent, dataset, appending to fragment).
|
||||
|
||||
### Why cloneNode Over Full Node Recycling
|
||||
|
||||
Full recycling (collecting old nodes, clearing attributes, reusing them) requires carefully resetting every `dataset.*` property that might have been set on a previous render. This is error-prone -- a stale `data-frequency-rank` from a previous subtitle appearing on a new token would cause incorrect styling. `cloneNode(false)` on a bare template is nearly as fast and produces a clean node every time.
|
||||
|
||||
### Expected Savings
|
||||
|
||||
`cloneNode(false)` is ~2-3x faster than `createElement` in most browser engines. For 10-15 tokens per subtitle: ~3-8ms saved per render cycle.
|
||||
|
||||
### Files Affected
|
||||
|
||||
- **Modified:** `src/renderer/subtitle-render.ts` -- template creation + cloneNode usage
|
||||
|
||||
---
|
||||
|
||||
## Combined Impact Summary
|
||||
|
||||
| Scenario | Before | After | Improvement |
|
||||
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/core/services/subtitle-prefetch.ts`
|
||||
- `src/core/services/subtitle-cue-parser.ts`
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||
- `src/main.ts` (wire up prefetch service)
|
||||
|
||||
### Test Files
|
||||
|
||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||
- Updated tests for annotation stage (same behavior, new implementation)
|
||||
- Updated tests for subtitle render (template cloning)
|
||||
@@ -32,7 +32,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (`toJellyfinOriginalStreamUrl`) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
@@ -46,6 +46,12 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (`hachidori-linked-import`): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Linked hosts that do not advertise `linked-import-v1` (Hachidori before 0.2.3) cannot receive uploads, and the import reports where to import the ZIP by hand. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
- Pure helpers where possible
|
||||
|
||||
@@ -63,7 +63,11 @@ the plugin artifact, persistent config, or session help.
|
||||
|
||||
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
||||
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
||||
including changes whose cue text and timings are identical. Native selection and clean clipboard
|
||||
including changes whose cue text and timings are identical. While open, the sidebar polls the
|
||||
cheap `SubtitleSidebarPlaybackState` (cached time, current line, `sourceKey`, `cuesRevision`; no
|
||||
mpv round-trips) every 80 ms for active-cue tracking. It fetches the full snapshot, which resolves
|
||||
the source over mpv IPC and carries every cue plus config, only on open, config reload, a
|
||||
`sourceKey`/`cuesRevision` change, and a ~2 s fallback. Native selection and clean clipboard
|
||||
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
||||
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
||||
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
||||
|
||||
@@ -21,6 +21,7 @@ Read when: finding internal docs or checking verification status
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||
| Testing guidelines | `docs/workflow/testing.md` | active | 2026-10-04 | test style and anti-patterns |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
|
||||
## Update Rules
|
||||
|
||||
@@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents.
|
||||
|
||||
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
||||
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
||||
- [Testing](./testing.md) - what a good test looks like and what not to write
|
||||
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
|
||||
- [Release Guide](../RELEASING.md) - tagged release workflow
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- read_when: writing, reviewing, or deleting tests -->
|
||||
|
||||
# Testing Guidelines
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-10-04
|
||||
Owner: Kyle Yasuda
|
||||
Read when: adding or changing tests
|
||||
|
||||
Which lanes to run lives in [Verification](./verification.md). This page covers what a
|
||||
good test looks like here.
|
||||
|
||||
## Test Behavior, Not Wiring
|
||||
|
||||
- Exercise a module through its public interface and assert outcomes: return values,
|
||||
resulting state, files written, rows in sqlite, messages sent to a fake process.
|
||||
- Prefer real collaborators that are cheap: tmp dirs, in-memory or tmp sqlite, fake
|
||||
executables, pure parsers. Fake only the edges (Electron, mpv socket, network).
|
||||
- Assert call order only when order is the behavior (startup sequencing, ABA races),
|
||||
and then assert relative order, not the whole call log.
|
||||
|
||||
## Do Not Write
|
||||
|
||||
- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`,
|
||||
`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
|
||||
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
|
||||
and asserting with regexes. If the invariant matters, extract the logic and run it
|
||||
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
|
||||
exception and belong in one parsed-YAML check.
|
||||
- "Stays deleted" tests for removed modules, flags, or selectors. `tsc` and review
|
||||
catch reintroduction.
|
||||
- Tests that restate constants, defaults, labels, or window options.
|
||||
- The same rule at several layers. Test a rule once where it lives; keep one
|
||||
end-to-end case (for the tokenizer, the golden corpus) for integration.
|
||||
|
||||
## Keep Tests Small
|
||||
|
||||
- Shared setup goes in a `makeDeps(overrides)` helper or a `*-test-harness.ts` file, not
|
||||
pasted into every test.
|
||||
- Near-identical cases become a table: `for (const c of cases) test(c.name, ...)`.
|
||||
- Inject timers, clocks, and loggers instead of patching globals or private members.
|
||||
- A bug fix gets a regression test for the behavior that broke, named after the
|
||||
behavior, not the ticket.
|
||||
|
||||
## Known Debt
|
||||
|
||||
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
|
||||
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
|
||||
behavioral one as the logic moves out of `main.ts` closures.
|
||||
@@ -3,7 +3,7 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-13
|
||||
Last verified: 2026-10-05
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
@@ -14,8 +14,9 @@ Read when: selecting the right verification lane for a change
|
||||
files in `package.json`.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||
`--single-process` restores the shared-process mode for debugging.
|
||||
one file cannot cascade into the rest of the lane. Files run in parallel, one
|
||||
worker per CPU by default; `--jobs 1` runs serially and `--single-process`
|
||||
restores the shared-process mode for debugging.
|
||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||
unit, and `scripts/**`.
|
||||
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
||||
@@ -57,12 +58,15 @@ bun run docs:build
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI: `bun run test:stats`
|
||||
- Overlay, renderer, mpv, or mining behavior that unit tests cannot show: `bun run build`,
|
||||
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
|
||||
Content checks run inside the electron-builder afterPack hook. See the
|
||||
[release guide](../RELEASING.md#package-contents-checks) for the
|
||||
installed-app verification checklist.
|
||||
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux (CI runs it in the Linux E2E job). It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
@@ -70,7 +74,7 @@ bun run docs:build
|
||||
|
||||
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
|
||||
`test:src` through a sharded coverage runner: one Bun coverage process per test
|
||||
file, then merged LCOV output.
|
||||
file (parallel, one per CPU unless `--jobs N`), then merged LCOV output.
|
||||
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
|
||||
source test gate, not a report-only step.
|
||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||
@@ -92,6 +96,54 @@ bun run docs:build
|
||||
- This is not a full Electron UI startup check. It does not require a display and
|
||||
makes no claims about renderer, window, tray, or mpv behavior.
|
||||
|
||||
## Headless End-to-End Harness
|
||||
|
||||
`bun run test:e2e` boots the built app against a real mpv and asserts on what the
|
||||
overlay renders and what reaches AnkiConnect. Use it instead of a live desktop test
|
||||
whenever the behavior does not depend on a specific compositor.
|
||||
|
||||
- Needs an existing `bun run build`, plus `mpv` and `ffmpeg` on `PATH` (and `Xvfb` on
|
||||
Linux). It is not part of `test:fast` or the handoff gate. The `e2e` job in
|
||||
`.github/workflows/ci.yml` runs it on Linux, macOS, and Windows for pull requests and
|
||||
`main`, outside the reusable quality gate, and uploads the session logs when it fails.
|
||||
- Every run is sandboxed in a temp dir: its own config and Electron profile, mpv IPC
|
||||
socket, and a fake AnkiConnect on a free port. It never touches the user's running
|
||||
SubMiner, Anki, or stats database. It is not offline: the app still asks AniList for
|
||||
cover art matching the clip's title.
|
||||
- Media is synthetic: an ffmpeg test-pattern clip, a three-cue Japanese `.srt`, and a
|
||||
tiny Yomitan dictionary covering those cues (`e2e/harness/fixtures.ts`). The harness
|
||||
imports the dictionary on a first boot, then restarts the app for the scenario.
|
||||
- Display: Linux runs everything inside a private Xvfb server, so no window reaches the
|
||||
desktop. macOS and Windows have no offscreen display server, so windows open in the
|
||||
current session there; `SUBMINER_E2E_DISPLAY=host` forces the same on Linux. The
|
||||
CI job runs all three. On macOS and Windows the app pre-creates a hidden modal window
|
||||
that loads the same renderer document, so window matching must include the
|
||||
`layer=visible` query (`OVERLAY_PAGE`).
|
||||
- The app is driven over the Chrome DevTools Protocol (DOM queries, input events,
|
||||
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
||||
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
||||
|
||||
For exploratory checks, keep one session up and poke at it:
|
||||
|
||||
```bash
|
||||
bun run e2e start & # prints the session JSON, stays up until stopped
|
||||
bun run e2e mpv seek 2 absolute+exact
|
||||
bun run e2e eval overlay 'document.getElementById("subtitleRoot").textContent'
|
||||
bun run e2e shot overlay /tmp/overlay.png
|
||||
bun run e2e app --mine-sentence
|
||||
bun run e2e anki # AnkiConnect requests received so far
|
||||
bun run e2e stop
|
||||
```
|
||||
|
||||
Out of scope: compositor-specific behavior (Hyprland placement, stacking, fullscreen,
|
||||
click-through), real GPU video output, and global shortcuts. Xvfb runs without a window
|
||||
manager or compositor, so the transparent overlay is not composited over the video;
|
||||
check overlay visuals with per-window screenshots, and use the live desktop for the rest.
|
||||
|
||||
## Dependency Audit Policy
|
||||
|
||||
- `bun audit --audit-level high` blocks the reusable quality gate.
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Keeps one isolated e2e session running so a change can be poked at by hand
|
||||
// (or by an agent) without touching the real desktop:
|
||||
//
|
||||
// bun run e2e start boot and stay up until `stop` or Ctrl-C
|
||||
// bun run e2e eval <page> <expression> evaluate JS in a window (or "main" for the main process)
|
||||
// bun run e2e shot <page> <file.png> save a screenshot of a window
|
||||
// bun run e2e mpv <command> [args...] send an mpv IPC command (args parsed as JSON)
|
||||
// bun run e2e app <args...> run a SubMiner CLI command against the instance
|
||||
// bun run e2e anki print the AnkiConnect requests received so far
|
||||
// bun run e2e stop
|
||||
//
|
||||
// <page> is a URL fragment: "overlay" is shorthand for the overlay window.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { connectCdp, connectCdpPage, listCdpTargets } from './harness/cdp';
|
||||
import { connectMpv } from './harness/mpv';
|
||||
import { OVERLAY_PAGE, runAppCommand, startE2eSession, type AppTarget } from './harness/session';
|
||||
import { waitUntil } from './harness/wait';
|
||||
|
||||
type SessionState = {
|
||||
pid: number;
|
||||
root: string;
|
||||
display: string;
|
||||
cdpPort: number;
|
||||
inspectorUrl: string;
|
||||
mpvSocketPath: string;
|
||||
ankiUrl: string;
|
||||
target: AppTarget;
|
||||
};
|
||||
|
||||
const stateFile = path.join(os.tmpdir(), 'subminer-e2e-session.json');
|
||||
|
||||
function readState(): SessionState {
|
||||
if (!fs.existsSync(stateFile)) {
|
||||
throw new Error('No e2e session is running. Start one with `bun run e2e start`.');
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SessionState;
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonOrString(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectNamedPage(state: SessionState, pageName: string) {
|
||||
if (pageName === 'main') return connectCdp(state.inspectorUrl, 'main process');
|
||||
const urlPart = pageName === 'overlay' ? OVERLAY_PAGE : pageName;
|
||||
const targets = await listCdpTargets(state.cdpPort);
|
||||
const target = targets.find((candidate) => candidate.url.includes(urlPart));
|
||||
if (!target) {
|
||||
const open = targets.map((candidate) => ` ${candidate.type} ${candidate.url.slice(0, 100)}`);
|
||||
throw new Error(`No window matches "${pageName}". Open targets:\n${open.join('\n')}`);
|
||||
}
|
||||
return connectCdpPage(target);
|
||||
}
|
||||
|
||||
async function withPage<T>(
|
||||
state: SessionState,
|
||||
pageName: string,
|
||||
use: (page: Awaited<ReturnType<typeof connectCdpPage>>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const page = await connectNamedPage(state, pageName);
|
||||
try {
|
||||
return await use(page);
|
||||
} finally {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (fs.existsSync(stateFile) && isAlive(readState().pid)) {
|
||||
throw new Error('An e2e session is already running. Stop it with `bun run e2e stop`.');
|
||||
}
|
||||
const session = await startE2eSession();
|
||||
const state: SessionState = {
|
||||
pid: process.pid,
|
||||
root: session.root,
|
||||
display: session.display,
|
||||
cdpPort: session.cdpPort,
|
||||
inspectorUrl: session.inspectorUrl,
|
||||
mpvSocketPath: session.mpvSocketPath,
|
||||
ankiUrl: session.anki.url,
|
||||
target: session.target,
|
||||
};
|
||||
// The state file grants main-process code evaluation via inspectorUrl, so keep it
|
||||
// owner-only. 'wx' creates it exclusively: it fails rather than follow a symlink
|
||||
// planted in the shared temp dir after the stale file is removed.
|
||||
fs.rmSync(stateFile, { force: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify(state, null, 2));
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
process.once('SIGINT', resolve);
|
||||
process.once('SIGTERM', resolve);
|
||||
});
|
||||
fs.rmSync(stateFile, { force: true });
|
||||
await session.dispose();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (command === 'start') return start();
|
||||
|
||||
const state = readState();
|
||||
switch (command) {
|
||||
case 'eval': {
|
||||
const [pageName, expression] = args;
|
||||
if (!pageName || !expression) throw new Error('Usage: eval <page> <expression>');
|
||||
const value = await withPage(state, pageName, (page) => page.evaluate<unknown>(expression));
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
case 'shot': {
|
||||
const [pageName, outputPath] = args;
|
||||
if (!pageName || !outputPath) throw new Error('Usage: shot <page> <file.png>');
|
||||
fs.writeFileSync(outputPath, await withPage(state, pageName, (page) => page.screenshot()));
|
||||
return;
|
||||
}
|
||||
case 'mpv': {
|
||||
if (args.length === 0) throw new Error('Usage: mpv <command> [args...]');
|
||||
const mpv = await connectMpv(state.mpvSocketPath);
|
||||
try {
|
||||
console.log(JSON.stringify((await mpv.command(...args.map(parseJsonOrString))) ?? null));
|
||||
} finally {
|
||||
mpv.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'app':
|
||||
await runAppCommand(state.target, args);
|
||||
return;
|
||||
case 'anki':
|
||||
console.log(JSON.stringify(await (await fetch(state.ankiUrl)).json(), null, 2));
|
||||
return;
|
||||
case 'stop':
|
||||
process.kill(state.pid, 'SIGTERM');
|
||||
await waitUntil(() => !isAlive(state.pid), { description: 'e2e session to exit' });
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown command "${command ?? ''}". See the header of e2e/cli.ts.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { waitUntil, type Truthy, type WaitOptions } from './wait';
|
||||
|
||||
export type CdpTarget = {
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
};
|
||||
|
||||
export type CdpPage = {
|
||||
/** Sends a raw DevTools protocol command, for anything the helpers below do not cover. */
|
||||
send: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
/** Evaluates an expression in the page, awaiting promises, and returns its JSON value. */
|
||||
evaluate: <T>(expression: string) => Promise<T>;
|
||||
/** Polls an expression until it is truthy and returns that value. */
|
||||
waitFor: <T>(expression: string, options: WaitOptions) => Promise<Truthy<T>>;
|
||||
/** Console output and uncaught exceptions seen since connecting, oldest first. */
|
||||
console: string[];
|
||||
/** PNG of the page as rendered by Chromium; needs no OS screen-capture permission. */
|
||||
screenshot: () => Promise<Buffer>;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type CdpReply = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
result?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type RemoteObject = { value?: unknown; description?: string };
|
||||
|
||||
// Renders a console/exception event as one log line.
|
||||
function formatConsoleEvent(method: string, params: Record<string, unknown>): string | null {
|
||||
if (method === 'Runtime.consoleAPICalled') {
|
||||
const args = Array.isArray(params.args) ? (params.args as RemoteObject[]) : [];
|
||||
const text = args.map((arg) => arg.description ?? JSON.stringify(arg.value)).join(' ');
|
||||
return `console.${String(params.type)}: ${text}`;
|
||||
}
|
||||
if (method === 'Runtime.exceptionThrown') {
|
||||
const details = params.exceptionDetails as
|
||||
| { text?: string; exception?: RemoteObject }
|
||||
| undefined;
|
||||
return `exception: ${details?.exception?.description ?? details?.text ?? 'unknown'}`;
|
||||
}
|
||||
if (method === 'Log.entryAdded') {
|
||||
const entry = params.entry as { level?: string; source?: string; text?: string } | undefined;
|
||||
return `${String(entry?.source)}.${String(entry?.level)}: ${String(entry?.text)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type EvaluateResult = {
|
||||
result: { value?: unknown };
|
||||
exceptionDetails?: { text: string; exception?: { description?: string } };
|
||||
};
|
||||
|
||||
export async function listCdpTargets(port: number): Promise<CdpTarget[]> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
return (await response.json()) as CdpTarget[];
|
||||
}
|
||||
|
||||
export function connectCdpPage(target: CdpTarget): Promise<CdpPage> {
|
||||
return connectCdp(target.webSocketDebuggerUrl, target.url);
|
||||
}
|
||||
|
||||
/** Connects to any DevTools endpoint: a renderer page or Electron's main-process inspector. */
|
||||
export async function connectCdp(webSocketDebuggerUrl: string, label: string): Promise<CdpPage> {
|
||||
const socket = new WebSocket(webSocketDebuggerUrl);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener('open', () => resolve(), { once: true });
|
||||
socket.addEventListener('error', () => reject(new Error(`CDP connection to ${label} failed`)), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
const pending = new Map<number, (reply: CdpReply) => void>();
|
||||
const consoleLines: string[] = [];
|
||||
let nextId = 1;
|
||||
socket.addEventListener('message', (event) => {
|
||||
const reply: CdpReply = JSON.parse(String(event.data));
|
||||
if (reply.id === undefined) {
|
||||
if (reply.method) {
|
||||
const line = formatConsoleEvent(reply.method, reply.params ?? {});
|
||||
if (line) consoleLines.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.get(reply.id)?.(reply);
|
||||
pending.delete(reply.id);
|
||||
});
|
||||
|
||||
const send = (method: string, params: Record<string, unknown> = {}) =>
|
||||
new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, (reply) => {
|
||||
if (reply.error) reject(new Error(`CDP ${method} failed: ${reply.error.message}`));
|
||||
else resolve(reply.result ?? {});
|
||||
});
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
|
||||
const evaluate = async <T>(expression: string): Promise<T> => {
|
||||
const reply = (await send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})) as EvaluateResult;
|
||||
if (reply.exceptionDetails) {
|
||||
const { text, exception } = reply.exceptionDetails;
|
||||
throw new Error(`Page evaluation threw: ${exception?.description ?? text}`);
|
||||
}
|
||||
return reply.result.value as T;
|
||||
};
|
||||
|
||||
await send('Runtime.enable');
|
||||
// The Node inspector behind Electron's main process has no Log domain.
|
||||
await send('Log.enable').catch(() => undefined);
|
||||
|
||||
return {
|
||||
send,
|
||||
evaluate,
|
||||
console: consoleLines,
|
||||
waitFor: <T>(expression: string, options: WaitOptions) =>
|
||||
waitUntil(() => evaluate<T>(expression), options),
|
||||
screenshot: async () => {
|
||||
const reply = await send('Page.captureScreenshot', { format: 'png' });
|
||||
return Buffer.from(String(reply.data), 'base64');
|
||||
},
|
||||
close: () => socket.close(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { stopProcess, type EnvDelta } from './process';
|
||||
|
||||
export const E2E_SCREEN = { width: 1280, height: 720 } as const;
|
||||
|
||||
export type E2eDisplay = {
|
||||
kind: 'xvfb' | 'host';
|
||||
env: EnvDelta;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Linux gets a private Xvfb server so no window ever reaches the real desktop.
|
||||
// macOS and Windows have no offscreen display server, so windows open in the
|
||||
// current GUI session there. SUBMINER_E2E_DISPLAY=host forces that on Linux too.
|
||||
export async function startDisplay(): Promise<E2eDisplay> {
|
||||
const requested = process.env.SUBMINER_E2E_DISPLAY?.trim().toLowerCase();
|
||||
if (requested && requested !== 'host' && requested !== 'xvfb') {
|
||||
throw new Error(`SUBMINER_E2E_DISPLAY must be "host" or "xvfb", got "${requested}"`);
|
||||
}
|
||||
const kind = requested ?? (process.platform === 'linux' ? 'xvfb' : 'host');
|
||||
if (kind === 'host') {
|
||||
return { kind, env: { set: {}, unset: [] }, stop: async () => {} };
|
||||
}
|
||||
return startXvfb();
|
||||
}
|
||||
|
||||
async function startXvfb(): Promise<E2eDisplay> {
|
||||
// -displayfd makes Xvfb pick a free display number and write it to fd 3 once
|
||||
// it accepts connections, so there is no port guessing and no startup sleep.
|
||||
const child = spawn(
|
||||
'Xvfb',
|
||||
[
|
||||
'-displayfd',
|
||||
'3',
|
||||
'-screen',
|
||||
'0',
|
||||
`${E2E_SCREEN.width}x${E2E_SCREEN.height}x24`,
|
||||
'-nolisten',
|
||||
'tcp',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'ignore', 'pipe'] },
|
||||
);
|
||||
const displayFd = child.stdio[3];
|
||||
if (!displayFd) throw new Error('Xvfb did not expose its display fd');
|
||||
|
||||
const displayNumber = await Promise.race([
|
||||
once(displayFd, 'data').then(([chunk]) => String(chunk).trim()),
|
||||
once(child, 'error').then(([error]) => {
|
||||
throw new Error(
|
||||
`Could not start Xvfb (${String(error)}). Install it or set SUBMINER_E2E_DISPLAY=host.`,
|
||||
);
|
||||
}),
|
||||
once(child, 'exit').then(([code]) => {
|
||||
throw new Error(`Xvfb exited during startup with code ${String(code)}`);
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
kind: 'xvfb',
|
||||
env: {
|
||||
set: { DISPLAY: `:${displayNumber}`, XDG_SESSION_TYPE: 'x11' },
|
||||
// Without these the app and mpv would pick the real Wayland session, or
|
||||
// take the compositor-specific code paths of the desktop running the tests.
|
||||
unset: [
|
||||
'WAYLAND_DISPLAY',
|
||||
'HYPRLAND_INSTANCE_SIGNATURE',
|
||||
'SWAYSOCK',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_DESKTOP',
|
||||
],
|
||||
},
|
||||
stop: () => stopProcess(child),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
export type AnkiRequest = { action: string; params: Record<string, unknown> };
|
||||
|
||||
export type FakeAnki = {
|
||||
url: string;
|
||||
/** Every AnkiConnect action received so far, with `multi` batches flattened. */
|
||||
requests: AnkiRequest[];
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const FAKE_ANKI_DECK = 'E2E';
|
||||
export const FAKE_ANKI_MODEL = 'Lapis';
|
||||
|
||||
const MODEL_FIELDS = [
|
||||
'Expression',
|
||||
'ExpressionFurigana',
|
||||
'ExpressionReading',
|
||||
'ExpressionAudio',
|
||||
'SelectionText',
|
||||
'MainDefinition',
|
||||
'Sentence',
|
||||
'SentenceFurigana',
|
||||
'SentenceAudio',
|
||||
'Picture',
|
||||
'Glossary',
|
||||
'IsWordAndSentenceCard',
|
||||
'IsClickCard',
|
||||
'IsSentenceCard',
|
||||
'IsAudioCard',
|
||||
'MiscInfo',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
if (!isRecord(body) || typeof body.action !== 'string') return null;
|
||||
const params = isRecord(body.params) ? body.params : {};
|
||||
if (body.action === 'multi') {
|
||||
return Array.isArray(params.actions) ? params.actions.map(handle) : [];
|
||||
}
|
||||
requests.push({ action: body.action, params });
|
||||
switch (body.action) {
|
||||
case 'version':
|
||||
return 6;
|
||||
case 'deckNames':
|
||||
return [FAKE_ANKI_DECK];
|
||||
case 'modelNames':
|
||||
return [FAKE_ANKI_MODEL];
|
||||
case 'modelFieldNames':
|
||||
return MODEL_FIELDS;
|
||||
case 'addNote':
|
||||
return nextNoteId++;
|
||||
case 'findNotes':
|
||||
case 'notesInfo':
|
||||
return [];
|
||||
case 'storeMediaFile':
|
||||
return params.filename ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
response.setHeader('content-type', 'application/json');
|
||||
// GET exposes the request log so the session CLI can read it from outside.
|
||||
if (request.method === 'GET') {
|
||||
response.end(JSON.stringify(requests));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
// Malformed bodies get the same null result AnkiConnect gives unknown actions.
|
||||
}
|
||||
response.end(JSON.stringify({ result: handle(body), error: null }));
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
stop: async () => {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { writeStoredZipAsync } from '../../src/shared/stored-zip';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const FIXTURE_DICTIONARY_TITLE = 'SubMiner E2E Fixture';
|
||||
export const FIXTURE_CLIP_SECONDS = 15;
|
||||
|
||||
/** Cues of the synthetic clip. Seek to `at` to land inside a cue. */
|
||||
export const FIXTURE_CUES = [
|
||||
{ start: 1, end: 4, at: 2, text: '今日はいい天気ですね。' },
|
||||
{ start: 5, end: 8, at: 6, text: '猫が好きです。' },
|
||||
{ start: 9, end: 12, at: 10, text: '明日は学校に行きます。' },
|
||||
] as const;
|
||||
|
||||
// [term, reading, part of speech, gloss] for every content word in FIXTURE_CUES.
|
||||
const FIXTURE_TERMS = [
|
||||
['今日', 'きょう', 'n', 'today'],
|
||||
['いい', 'いい', 'adj-i', 'good'],
|
||||
['天気', 'てんき', 'n', 'weather'],
|
||||
['猫', 'ねこ', 'n', 'cat'],
|
||||
['好き', 'すき', 'adj-na', 'liked'],
|
||||
['明日', 'あした', 'n', 'tomorrow'],
|
||||
['学校', 'がっこう', 'n', 'school'],
|
||||
['行く', 'いく', 'v5', 'to go'],
|
||||
] as const;
|
||||
|
||||
function srtTimestamp(seconds: number): string {
|
||||
return `00:00:${String(seconds).padStart(2, '0')},000`;
|
||||
}
|
||||
|
||||
export function writeFixtureSubtitles(outputPath: string): void {
|
||||
const blocks = FIXTURE_CUES.map(
|
||||
(cue, index) =>
|
||||
`${index + 1}\n${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}\n${cue.text}\n`,
|
||||
);
|
||||
fs.writeFileSync(outputPath, blocks.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
/** Renders a deterministic test-pattern clip with a sine tone, so no real media is needed. */
|
||||
export async function generateFixtureClip(outputPath: string): Promise<void> {
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-nostdin',
|
||||
'-v',
|
||||
'error',
|
||||
'-y',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
`testsrc2=size=640x360:rate=24:duration=${FIXTURE_CLIP_SECONDS}`,
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
`sine=frequency=440:duration=${FIXTURE_CLIP_SECONDS}`,
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-c:a',
|
||||
'aac',
|
||||
outputPath,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Writes a minimal Yomitan term dictionary so the tokenizer has real entries to match. */
|
||||
export async function buildFixtureDictionary(outputPath: string): Promise<void> {
|
||||
const index = {
|
||||
title: FIXTURE_DICTIONARY_TITLE,
|
||||
revision: '1',
|
||||
format: 3,
|
||||
author: 'SubMiner',
|
||||
description: 'Synthetic dictionary for the e2e harness.',
|
||||
};
|
||||
const termBank = FIXTURE_TERMS.map(([term, reading, partOfSpeech, gloss], sequence) => [
|
||||
term,
|
||||
reading,
|
||||
'',
|
||||
partOfSpeech,
|
||||
0,
|
||||
[gloss],
|
||||
sequence + 1,
|
||||
'',
|
||||
]);
|
||||
await writeStoredZipAsync(outputPath, [
|
||||
{ name: 'index.json', data: Buffer.from(JSON.stringify(index), 'utf8') },
|
||||
{ name: 'term_bank_1.json', data: Buffer.from(JSON.stringify(termBank), 'utf8') },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import type { E2eDisplay } from './display';
|
||||
import { E2E_SCREEN } from './display';
|
||||
import { applyEnvDelta } from './process';
|
||||
import { waitUntil } from './wait';
|
||||
|
||||
export type MpvClient = {
|
||||
/** Sends one mpv JSON IPC command and resolves with its `data` field. */
|
||||
command: (...args: unknown[]) => Promise<unknown>;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type MpvReply = { request_id?: number; error?: string; data?: unknown };
|
||||
|
||||
export async function startMpv(options: {
|
||||
socketPath: string;
|
||||
mediaPath: string;
|
||||
subtitlePath: string;
|
||||
logPath: string;
|
||||
display: E2eDisplay;
|
||||
}): Promise<ChildProcess> {
|
||||
const log = fs.openSync(options.logPath, 'w');
|
||||
const child = spawn(
|
||||
'mpv',
|
||||
[
|
||||
'--no-config',
|
||||
'--no-terminal',
|
||||
'--pause',
|
||||
'--keep-open=yes',
|
||||
'--ao=null',
|
||||
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
|
||||
// Xvfb has no GPU; the software X11 output always works there.
|
||||
...(options.display.kind === 'xvfb' ? ['--vo=x11'] : []),
|
||||
`--input-ipc-server=${options.socketPath}`,
|
||||
`--sub-file=${options.subtitlePath}`,
|
||||
options.mediaPath,
|
||||
],
|
||||
{ env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] },
|
||||
);
|
||||
fs.closeSync(log);
|
||||
await Promise.race([
|
||||
once(child, 'spawn'),
|
||||
once(child, 'error').then(([error]) => {
|
||||
throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`);
|
||||
}),
|
||||
]);
|
||||
return child;
|
||||
}
|
||||
|
||||
function tryConnect(socketPath: string): Promise<net.Socket | null> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(socketPath);
|
||||
socket.once('connect', () => resolve(socket));
|
||||
socket.once('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectMpv(socketPath: string): Promise<MpvClient> {
|
||||
const socket = await waitUntil(() => tryConnect(socketPath), {
|
||||
description: `mpv IPC socket ${socketPath}`,
|
||||
});
|
||||
const pending = new Map<number, (reply: MpvReply) => void>();
|
||||
let nextRequestId = 1;
|
||||
let buffer = '';
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const reply: MpvReply = JSON.parse(line);
|
||||
if (reply.request_id === undefined) continue;
|
||||
pending.get(reply.request_id)?.(reply);
|
||||
pending.delete(reply.request_id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
command: (...args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const requestId = nextRequestId++;
|
||||
pending.set(requestId, (reply) => {
|
||||
if (reply.error === 'success') resolve(reply.data);
|
||||
else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`));
|
||||
});
|
||||
socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`);
|
||||
}),
|
||||
close: () => socket.destroy(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
|
||||
export type EnvDelta = { set: Record<string, string>; unset: string[] };
|
||||
|
||||
export function applyEnvDelta(base: NodeJS.ProcessEnv, ...deltas: EnvDelta[]): NodeJS.ProcessEnv {
|
||||
const env = { ...base };
|
||||
for (const delta of deltas) {
|
||||
for (const name of delta.unset) delete env[name];
|
||||
Object.assign(env, delta.set);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Terminates a child and waits for it to exit, escalating to SIGKILL after `graceMs`. */
|
||||
export async function stopProcess(child: ChildProcess, graceMs = 5_000): Promise<void> {
|
||||
// A child that never spawned (missing binary) has no pid and never emits exit.
|
||||
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, 'exit');
|
||||
child.kill();
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
|
||||
await exited;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { connectCdp, connectCdpPage, listCdpTargets, type CdpPage } from './cdp';
|
||||
import { startDisplay, type E2eDisplay } from './display';
|
||||
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL, startFakeAnki, type FakeAnki } from './fake-anki';
|
||||
import { buildFixtureDictionary, generateFixtureClip, writeFixtureSubtitles } from './fixtures';
|
||||
import { connectMpv, startMpv, type MpvClient } from './mpv';
|
||||
import { applyEnvDelta, stopProcess, type EnvDelta } from './process';
|
||||
import { waitUntil } from './wait';
|
||||
import {
|
||||
shouldForceX11ElectronBackend,
|
||||
X11_ELECTRON_BOOTSTRAP_ENV,
|
||||
} from '../../src/core/utils/electron-backend';
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
|
||||
/** URL fragments identifying the app's windows among the CDP targets. The modal
|
||||
* window loads the same document with `layer=modal`, so the layer is required. */
|
||||
export const OVERLAY_PAGE = 'renderer/index.html?layer=visible';
|
||||
const YOMITAN_SETTINGS_PAGE = 'settings.html';
|
||||
|
||||
type JsonObject = { [key: string]: JsonValue };
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||
|
||||
export type E2eSessionOptions = {
|
||||
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||
config?: JsonObject;
|
||||
};
|
||||
|
||||
export type AppTarget = {
|
||||
/** Environment changes that point a process at the isolated instance. */
|
||||
env: EnvDelta;
|
||||
/** Electron/Chromium switches every launch needs. */
|
||||
electronArgs: string[];
|
||||
/** Receives stdout and stderr of every launch. */
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
export type E2eSession = {
|
||||
/** Temp dir holding the isolated config, fixtures, sockets, and logs. */
|
||||
root: string;
|
||||
display: E2eDisplay['kind'];
|
||||
cdpPort: number;
|
||||
/** DevTools endpoint of the Electron main process (Node inspector). */
|
||||
inspectorUrl: string;
|
||||
mpvSocketPath: string;
|
||||
/** How to start a process that talks to this isolated instance. */
|
||||
target: AppTarget;
|
||||
mpv: MpvClient;
|
||||
anki: FakeAnki;
|
||||
/** Connects to the first window or frame whose URL contains `urlPart`, waiting for it to exist. */
|
||||
openPage: (urlPart: string) => Promise<CdpPage>;
|
||||
/** Evaluates in the main process; reach modules via `process.mainModule.require`. */
|
||||
mainProcess: () => Promise<CdpPage>;
|
||||
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
|
||||
runAppCommand: (...args: string[]) => Promise<void>;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeConfig(base: JsonObject, overrides: JsonObject): JsonObject {
|
||||
const merged = { ...base };
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
const existing = merged[key];
|
||||
merged[key] =
|
||||
isJsonObject(existing) && isJsonObject(value) ? mergeConfig(existing, value) : value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Everything that could reach outside the sandbox (network services, the real
|
||||
// Anki, ports the user's own SubMiner holds) is off or pointed at a fake.
|
||||
function buildConfig(mpvSocketPath: string, ankiUrl: string): JsonObject {
|
||||
return {
|
||||
mpv: { socketPath: mpvSocketPath, aniskipEnabled: false },
|
||||
ankiConnect: {
|
||||
url: ankiUrl,
|
||||
deck: FAKE_ANKI_DECK,
|
||||
proxy: { enabled: false },
|
||||
isLapis: { enabled: true, sentenceCardModel: FAKE_ANKI_MODEL },
|
||||
},
|
||||
stats: { autoStartServer: false },
|
||||
updates: { enabled: false },
|
||||
discordPresence: { enabled: false },
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Outside Electron the package's entry point is the path of its binary, and
|
||||
// resolving it downloads the binary when an install skipped that step (CI).
|
||||
function resolveElectronBinary(): string {
|
||||
const binary: unknown = require('electron');
|
||||
if (typeof binary !== 'string') throw new Error('Could not resolve the Electron binary path');
|
||||
return binary;
|
||||
}
|
||||
|
||||
// Electron announces its main-process inspector on stderr; `offset` skips earlier launches.
|
||||
function readInspectorUrl(logPath: string, byteOffset: number): string | null {
|
||||
const tail = fs.readFileSync(logPath).subarray(byteOffset).toString('utf8');
|
||||
return /Debugger listening on (ws:\/\/\S+)/.exec(tail)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function readDevToolsPort(portFile: string): number | null {
|
||||
try {
|
||||
const port = Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]);
|
||||
return port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnApp(target: AppTarget, electronArgs: string[], args: string[]): ChildProcess {
|
||||
const log = fs.openSync(target.logPath, 'a');
|
||||
const child = spawn(
|
||||
resolveElectronBinary(),
|
||||
[...target.electronArgs, ...electronArgs, repoRoot, ...args],
|
||||
{ cwd: repoRoot, env: applyEnvDelta(process.env, target.env), stdio: ['ignore', log, log] },
|
||||
);
|
||||
fs.closeSync(log);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Runs a CLI command against the running instance and waits for it to finish. */
|
||||
export async function runAppCommand(target: AppTarget, args: string[]): Promise<void> {
|
||||
const child = spawnApp(target, [], args);
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
||||
const [code] = await once(child, 'exit');
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`SubMiner ${args.join(' ')} exited with ${String(code)}; see ${target.logPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
|
||||
const target = await waitUntil(
|
||||
async () =>
|
||||
(await listCdpTargets(cdpPort)).find((candidate) => candidate.url.includes(urlPart)),
|
||||
{ description: `app window matching "${urlPart}"`, timeoutMs: 30_000 },
|
||||
);
|
||||
const page = await connectCdpPage(target);
|
||||
// A target is listed before its document exists; callers expect a usable DOM.
|
||||
await page.waitFor(`document.readyState === "complete"`, {
|
||||
description: `"${urlPart}" document to load`,
|
||||
});
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
|
||||
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
|
||||
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-e2e-'));
|
||||
const configHome = path.join(root, 'config');
|
||||
const userDataDir = path.join(configHome, 'SubMiner');
|
||||
const mediaPath = path.join(root, 'clip.mkv');
|
||||
const subtitlePath = path.join(root, 'clip.srt');
|
||||
const dictionaryPath = path.join(root, 'fixture-dictionary.zip');
|
||||
const mpvSocketPath =
|
||||
process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\${path.basename(root)}-mpv`
|
||||
: path.join(root, 'mpv.sock');
|
||||
|
||||
// Teardown steps, run in reverse. Also used to unwind a failed startup.
|
||||
const teardown: Array<() => Promise<void> | void> = [];
|
||||
const dispose = async (): Promise<void> => {
|
||||
for (const step of teardown.reverse()) await step();
|
||||
teardown.length = 0;
|
||||
if (process.env.SUBMINER_E2E_KEEP !== '1') {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
writeFixtureSubtitles(subtitlePath);
|
||||
await Promise.all([generateFixtureClip(mediaPath), buildFixtureDictionary(dictionaryPath)]);
|
||||
|
||||
const display = await startDisplay();
|
||||
teardown.push(display.stop);
|
||||
const anki = await startFakeAnki();
|
||||
teardown.push(anki.stop);
|
||||
|
||||
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||
|
||||
const env: EnvDelta = {
|
||||
set: {
|
||||
...display.env.set,
|
||||
// The app derives its config dir, Electron profile, and control socket from these.
|
||||
...(process.platform === 'win32'
|
||||
? { APPDATA: configHome }
|
||||
: { XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: path.join(root, 'data') }),
|
||||
SUBMINER_APP_LOG: path.join(root, 'app.log'),
|
||||
// Stay attached instead of re-spawning detached, so the harness owns the process.
|
||||
SUBMINER_BACKGROUND_CHILD: '1',
|
||||
},
|
||||
unset: [...display.env.unset, 'ELECTRON_RUN_AS_NODE'],
|
||||
};
|
||||
// --enable-logging routes renderer console output into the app log too.
|
||||
const electronArgs = ['--password-store=basic', '--enable-logging=stderr'];
|
||||
// Outside Hyprland/Sway the app re-spawns itself detached onto the X11
|
||||
// backend. Start it there directly so the process we spawn is the app.
|
||||
if (shouldForceX11ElectronBackend(applyEnvDelta(process.env, env))) {
|
||||
env.set[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||
electronArgs.push('--ozone-platform=x11');
|
||||
}
|
||||
// CI containers and runners lack the user namespaces Chromium's sandbox needs.
|
||||
if (process.platform === 'linux' && process.env.CI) electronArgs.push('--no-sandbox');
|
||||
const target: AppTarget = { env, electronArgs, logPath: path.join(root, 'app-stdio.log') };
|
||||
|
||||
const launchApp = async (
|
||||
args: string[],
|
||||
): Promise<{ child: ChildProcess; cdpPort: number; inspectorUrl: string }> => {
|
||||
// With port 0 Chromium and Node each pick a free port; Chromium records
|
||||
// its port in the profile dir, Node prints its endpoint to stderr.
|
||||
const portFile = path.join(userDataDir, 'DevToolsActivePort');
|
||||
fs.rmSync(portFile, { force: true });
|
||||
const logOffset = fs.existsSync(target.logPath) ? fs.statSync(target.logPath).size : 0;
|
||||
const child = spawnApp(
|
||||
target,
|
||||
['--remote-debugging-port=0', '--inspect=0'],
|
||||
[...args, '--log-level', 'debug'],
|
||||
);
|
||||
const stop = () => stopProcess(child);
|
||||
teardown.push(stop);
|
||||
const cdpPort = await waitUntil(
|
||||
() => {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SubMiner exited with code ${child.exitCode} during startup`);
|
||||
}
|
||||
return readDevToolsPort(portFile);
|
||||
},
|
||||
{ description: 'SubMiner DevTools port', timeoutMs: 30_000 },
|
||||
);
|
||||
const inspectorUrl = await waitUntil(() => readInspectorUrl(target.logPath, logOffset), {
|
||||
description: 'SubMiner main-process inspector endpoint',
|
||||
});
|
||||
return { child, cdpPort, inspectorUrl };
|
||||
};
|
||||
|
||||
// First boot: import the fixture dictionary through Yomitan's settings page.
|
||||
// Without one the tokenizer finds nothing and first-run setup takes over.
|
||||
const seeder = await launchApp(['--yomitan']);
|
||||
const settings = await openPage(seeder.cdpPort, YOMITAN_SETTINGS_PAGE);
|
||||
await settings.waitFor('globalThis.__subminerYomitanSettingsAutomation?.ready === true', {
|
||||
description: 'Yomitan settings automation bridge',
|
||||
});
|
||||
const archive = fs.readFileSync(dictionaryPath).toString('base64');
|
||||
await settings.evaluate(
|
||||
`globalThis.__subminerYomitanSettingsAutomation
|
||||
.importDictionaryArchiveBase64(${JSON.stringify(archive)}, "fixture-dictionary.zip")
|
||||
.then(() => true)`,
|
||||
);
|
||||
settings.close();
|
||||
await stopProcess(seeder.child);
|
||||
|
||||
const mpvProcess = await startMpv({
|
||||
socketPath: mpvSocketPath,
|
||||
mediaPath,
|
||||
subtitlePath,
|
||||
logPath: path.join(root, 'mpv.log'),
|
||||
display,
|
||||
});
|
||||
teardown.push(() => stopProcess(mpvProcess));
|
||||
const mpv = await connectMpv(mpvSocketPath);
|
||||
teardown.push(mpv.close);
|
||||
|
||||
const app = await launchApp(['--start']);
|
||||
// mpv starts paused and the app resumes it once the overlay and tokenizer
|
||||
// are ready, so that resume marks the end of startup.
|
||||
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
|
||||
description: 'SubMiner to resume playback after overlay startup',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
// Hand scenarios a paused player. The app retries its resume for a moment,
|
||||
// so keep pausing until the pause has held for longer than that retry window.
|
||||
let pausedSince: number | null = null;
|
||||
await waitUntil(
|
||||
async () => {
|
||||
if ((await mpv.command('get_property', 'pause')) !== true) {
|
||||
pausedSince = null;
|
||||
await mpv.command('set_property', 'pause', true);
|
||||
return false;
|
||||
}
|
||||
pausedSince ??= Date.now();
|
||||
return Date.now() - pausedSince >= 1_500;
|
||||
},
|
||||
{ description: 'playback to stay paused after startup', timeoutMs: 40_000 },
|
||||
);
|
||||
const pages: CdpPage[] = [];
|
||||
teardown.push(() => pages.forEach((page) => page.close()));
|
||||
|
||||
return {
|
||||
root,
|
||||
display: display.kind,
|
||||
cdpPort: app.cdpPort,
|
||||
inspectorUrl: app.inspectorUrl,
|
||||
mpvSocketPath,
|
||||
target,
|
||||
mpv,
|
||||
anki,
|
||||
openPage: async (urlPart) => {
|
||||
const page = await openPage(app.cdpPort, urlPart);
|
||||
pages.push(page);
|
||||
return page;
|
||||
},
|
||||
mainProcess: async () => {
|
||||
const page = await connectCdp(app.inspectorUrl, 'main process');
|
||||
pages.push(page);
|
||||
return page;
|
||||
},
|
||||
runAppCommand: (...args) => runAppCommand(target, args),
|
||||
dispose,
|
||||
};
|
||||
} catch (error) {
|
||||
// Keep the logs: a failed startup is exactly when they are needed.
|
||||
process.env.SUBMINER_E2E_KEEP = '1';
|
||||
await dispose();
|
||||
throw new Error(`E2E session failed to start (logs kept in ${root})`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type WaitOptions = {
|
||||
description: string;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
export type Truthy<T> = Exclude<T, false | 0 | '' | null | undefined>;
|
||||
|
||||
/** Polls `probe` until it returns a truthy value, then returns that value. */
|
||||
export async function waitUntil<T>(
|
||||
probe: () => T | Promise<T>,
|
||||
{ description, timeoutMs = 15_000, intervalMs = 100 }: WaitOptions,
|
||||
): Promise<Truthy<T>> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const value = await probe();
|
||||
if (value) return value as Truthy<T>;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp';
|
||||
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki';
|
||||
import { FIXTURE_CUES } from './harness/fixtures';
|
||||
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
||||
import { waitUntil } from './harness/wait';
|
||||
|
||||
type Started = { session: E2eSession; overlay: CdpPage };
|
||||
|
||||
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
||||
// session boots inside whichever test runs first and every test budgets for it.
|
||||
const TEST_TIMEOUT_MS = 120_000;
|
||||
let started: Promise<Started> | undefined;
|
||||
|
||||
function useSession(): Promise<Started> {
|
||||
started ??= (async () => {
|
||||
const session = await startE2eSession();
|
||||
try {
|
||||
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||
} catch (error) {
|
||||
await session.dispose();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
return started;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
const running = await started?.catch(() => undefined);
|
||||
await running?.session.dispose();
|
||||
});
|
||||
|
||||
type RenderedToken = { surface: string; headword: string | null };
|
||||
|
||||
// Parks playback inside a cue and waits for the overlay to show that line.
|
||||
async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToken[]> {
|
||||
const { session, overlay } = await useSession();
|
||||
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||
// One expression reads the line and its tokens together, so a re-render
|
||||
// between two round trips cannot hand back a half-updated subtitle.
|
||||
return overlay
|
||||
.waitFor<RenderedToken[] | false>(
|
||||
`(() => {
|
||||
const root = document.getElementById('subtitleRoot');
|
||||
const words = Array.from(root.querySelectorAll('.word'), (word) => ({
|
||||
surface: word.textContent,
|
||||
headword: word.getAttribute('data-headword'),
|
||||
}));
|
||||
return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words;
|
||||
})()`,
|
||||
{ description: `overlay to render "${cue.text}" tokenized` },
|
||||
)
|
||||
.catch(async (error: unknown) => {
|
||||
// Say what the overlay was showing, what main thinks the line is, and
|
||||
// what the renderer logged, so a dropped update can be placed.
|
||||
const state = await overlay.evaluate<string>(
|
||||
`(async () => JSON.stringify({
|
||||
visibility: document.visibilityState,
|
||||
text: document.getElementById('subtitleRoot').textContent,
|
||||
html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200),
|
||||
mainLine: (await window.electronAPI.getCurrentSubtitle())?.text,
|
||||
errorToast: document.getElementById('overlayErrorToast')?.textContent,
|
||||
}))()`,
|
||||
);
|
||||
// Diagnostics must not replace the cue failure if the inspector is gone too.
|
||||
const windows = await session
|
||||
.mainProcess()
|
||||
.then((main) =>
|
||||
main.evaluate<string>(
|
||||
`JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({
|
||||
id: w.id,
|
||||
title: w.getTitle(),
|
||||
url: w.webContents.getURL().split('/').pop(),
|
||||
visible: w.isVisible(),
|
||||
loading: w.webContents.isLoading(),
|
||||
documentLoaded: w.__subminerOverlayDocumentLoaded,
|
||||
contentReady: w.__subminerOverlayContentReady,
|
||||
})))`,
|
||||
),
|
||||
)
|
||||
.catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`);
|
||||
const targets = (await listCdpTargets(session.cdpPort))
|
||||
.filter((t) => t.type === 'page')
|
||||
.map((t) => t.url.split('/').pop());
|
||||
const log = overlay.console.slice(-20).join('\n');
|
||||
throw new Error(
|
||||
`Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`,
|
||||
{ cause: error },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||
}
|
||||
|
||||
test(
|
||||
'overlay renders the current mpv subtitle line as dictionary tokens',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const tokens = await showCue(FIXTURE_CUES[0]);
|
||||
|
||||
assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens));
|
||||
assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const tokens = await showCue(FIXTURE_CUES[2]);
|
||||
|
||||
assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mining a sentence card sends the line with generated audio and image to Anki',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
await showCue(cue);
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const update = await waitUntil(
|
||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||
assert.equal(added.length, 1);
|
||||
assert.partialDeepStrictEqual(added[0]?.params.note, {
|
||||
deckName: FAKE_ANKI_DECK,
|
||||
modelName: FAKE_ANKI_MODEL,
|
||||
fields: { Sentence: cue.text },
|
||||
});
|
||||
|
||||
const stored = session.anki.requests
|
||||
.filter((request) => request.action === 'storeMediaFile')
|
||||
.map((request) => String(request.params.filename));
|
||||
assert.ok(
|
||||
stored.some((name) => name.endsWith('.mp3')),
|
||||
`no audio in ${stored.join(', ')}`,
|
||||
);
|
||||
assert.ok(
|
||||
stored.some((name) => name.endsWith('.jpg')),
|
||||
`no image in ${stored.join(', ')}`,
|
||||
);
|
||||
|
||||
const updatedFields = JSON.stringify(update.params);
|
||||
for (const filename of stored) assert.ok(updatedFields.includes(filename), filename);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
await showCue(FIXTURE_CUES[0]);
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
|
||||
{ description: 'sidebar to list every fixture cue' },
|
||||
);
|
||||
|
||||
for (const [index, cue] of FIXTURE_CUES.entries()) {
|
||||
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
|
||||
{ description: `sidebar to highlight cue ${index}` },
|
||||
);
|
||||
}
|
||||
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||
{ description: 'sidebar to close' },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
await showCue(FIXTURE_CUES[0]);
|
||||
const word = await overlay.evaluate<{ x: number; y: number }>(
|
||||
`(() => {
|
||||
const words = Array.from(document.querySelectorAll('#subtitleRoot .word'));
|
||||
const box = words.find((word) => word.textContent === '天気').getBoundingClientRect();
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
})()`,
|
||||
);
|
||||
|
||||
// Yomitan's default scan modifier is Shift, and it scans on mouse movement,
|
||||
// so approach the word, hold Shift, then move across it.
|
||||
const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
|
||||
const move = (x: number, modifiers = 0) =>
|
||||
overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers });
|
||||
await move(word.x - 30);
|
||||
await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 });
|
||||
await move(word.x - 2, 8);
|
||||
await move(word.x, 8);
|
||||
|
||||
await overlay.waitFor(
|
||||
`document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`,
|
||||
{ description: 'overlay to mark the Yomitan popup visible' },
|
||||
);
|
||||
// Yomitan keeps more than one popup frame around, so read whichever
|
||||
// one holds the entry instead of trusting target order.
|
||||
let seen: string[] = [];
|
||||
const entry = await waitUntil(
|
||||
async () => {
|
||||
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
|
||||
target.url.includes('popup.html'),
|
||||
);
|
||||
seen = [];
|
||||
for (const target of targets) {
|
||||
// A frame can go away between listing and connecting; skip it.
|
||||
const frame = await connectCdpPage(target).catch(() => undefined);
|
||||
if (!frame) continue;
|
||||
// textContent, not innerText: the entry counts once it is in the DOM,
|
||||
// even where a GPU-less runner never lays the frame out.
|
||||
const text = await frame.evaluate<string>('document.body?.textContent ?? ""');
|
||||
frame.close();
|
||||
seen.push(text);
|
||||
}
|
||||
return seen.find((text) => text.includes('weather'));
|
||||
},
|
||||
{ description: 'a popup frame to show the fixture dictionary entry' },
|
||||
).catch((error: unknown) => {
|
||||
const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200));
|
||||
throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error });
|
||||
});
|
||||
await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift });
|
||||
assert.match(entry, /天気/);
|
||||
},
|
||||
);
|
||||
@@ -6,11 +6,9 @@ import type { LauncherCommandContext } from './context.js';
|
||||
import { runConfigCommand } from './config-command.js';
|
||||
import { runDictionaryCommand } from './dictionary-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runLogsCommand } from './logs-command.js';
|
||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
|
||||
import { runAppPassthroughCommand } from './app-command.js';
|
||||
import { runStatsCommand } from './stats-command.js';
|
||||
import { runUpdateCommand } from './update-command.js';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
@@ -150,74 +148,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor command forwards refresh-known-words to app binary', () => {
|
||||
const context = createContext();
|
||||
context.args.doctor = true;
|
||||
context.args.doctorRefreshKnownWords = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = runDoctorCommand(context, {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
||||
});
|
||||
|
||||
test('logs command exports logs and writes archive path', () => {
|
||||
const writes: string[] = [];
|
||||
const context = createContext();
|
||||
context.args.logsExport = true;
|
||||
context.processAdapter = {
|
||||
...context.processAdapter,
|
||||
writeStdout: (text) => writes.push(text),
|
||||
};
|
||||
|
||||
const handled = runLogsCommand(context, {
|
||||
exportLogsArchive: () => ({
|
||||
zipPath: '/tmp/subminer-logs.zip',
|
||||
exportedFiles: ['/tmp/app.log'],
|
||||
mode: 'current-day',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
|
||||
});
|
||||
|
||||
test('logs command ignores unrelated launcher commands', () => {
|
||||
const context = createContext();
|
||||
|
||||
assert.equal(runLogsCommand(context), false);
|
||||
});
|
||||
|
||||
test('app command starts default macOS background app detached from launcher', () => {
|
||||
const context = createContext();
|
||||
context.args.appPassthrough = true;
|
||||
context.args.appArgs = [];
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchYoutubeBrowserDetached: () => calls.push('youtube'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
|
||||
});
|
||||
|
||||
test('app command starts default Linux background app detached from launcher', () => {
|
||||
test('app command starts default background app detached from launcher app detached from launcher', () => {
|
||||
const context = createContext();
|
||||
context.args.appPassthrough = true;
|
||||
context.args.appArgs = [];
|
||||
@@ -389,49 +320,6 @@ test('dictionary command forwards candidate and selection modes to app binary',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dictionary command returns after app handoff starts', () => {
|
||||
const context = createContext();
|
||||
context.args.dictionary = true;
|
||||
|
||||
const handled = runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
});
|
||||
|
||||
test('update command runs direct Linux release update without launching Electron', async () => {
|
||||
const context = createContext();
|
||||
context.args.update = true;
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = await runUpdateCommand(context, {
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected Electron launch');
|
||||
},
|
||||
runDirectReleaseUpdate: async (request) => {
|
||||
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
|
||||
return {
|
||||
appImage: { status: 'not-found' },
|
||||
launcher: { status: 'updated' },
|
||||
supportAssets: [{ status: 'skipped' }],
|
||||
};
|
||||
},
|
||||
readMainConfig: () => null,
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [
|
||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||
'info:AppImage update: not-found',
|
||||
'info:Launcher update: updated',
|
||||
'info:Support assets update: skipped',
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
@@ -449,16 +337,41 @@ test('stats command launches attached app command with response path', async ()
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats background command launches attached daemon control command with response path', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
|
||||
const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json'];
|
||||
|
||||
const statsForwardingCases = [
|
||||
{
|
||||
name: 'stats background command launches the daemon start command',
|
||||
overrides: { statsBackground: true },
|
||||
expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS],
|
||||
},
|
||||
{
|
||||
name: 'stats stop command forwards the daemon stop flag',
|
||||
overrides: { statsStop: true },
|
||||
expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS],
|
||||
},
|
||||
{
|
||||
name: 'stats cleanup command forwards cleanup vocab flags',
|
||||
overrides: { statsCleanup: true, statsCleanupVocab: true },
|
||||
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'],
|
||||
},
|
||||
{
|
||||
name: 'stats cleanup command forwards the lifetime rebuild flag',
|
||||
overrides: { statsCleanup: true, statsCleanupLifetime: true },
|
||||
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of statsForwardingCases) {
|
||||
test(`${c.name} to the app`, async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, ...c.overrides });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
||||
]);
|
||||
assert.deepEqual(harness.forwarded, [c.expected]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('stats command waits for attached app exit after startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
@@ -503,45 +416,6 @@ test('stats command throws when attached app exits non-zero after startup respon
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-vocab',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats stop command forwards stop flag to the app', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||
let waitedForResponse = false;
|
||||
@@ -563,30 +437,6 @@ test('stats stop command exits on process exit without waiting for startup respo
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupLifetime: true,
|
||||
});
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-lifetime',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command throws when stats response reports an error', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
|
||||
@@ -692,36 +542,3 @@ test('stats command aborts pending response wait when attached app fails to spaw
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
let aborted = false;
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 2;
|
||||
},
|
||||
waitForStatsResponse: async (_responsePath, signal) =>
|
||||
await new Promise((resolve) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({ ok: false, error: 'aborted' });
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
}, /Stats app exited before startup response \(status 2\)\./);
|
||||
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||
const { args, appPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,72 +4,22 @@ import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { state } from '../mpv.js';
|
||||
import { state, type startMpv } from '../mpv.js';
|
||||
import { makeLauncherArgs } from '../test-support/args.js';
|
||||
import { withEnv } from '../test-support/env.js';
|
||||
|
||||
function createContext(): LauncherCommandContext {
|
||||
type PlaybackDeps = Parameters<typeof runPlaybackCommandWithDeps>[1];
|
||||
type StartMpvOptions = NonNullable<Parameters<typeof startMpv>[6]>;
|
||||
|
||||
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw';
|
||||
|
||||
function makePluginConfig(
|
||||
overrides: Partial<LauncherCommandContext['pluginRuntimeConfig']> = {},
|
||||
): LauncherCommandContext['pluginRuntimeConfig'] {
|
||||
return {
|
||||
args: {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||
targetKind: 'url',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
youtubeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
launchMode: 'normal',
|
||||
},
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
@@ -78,7 +28,29 @@ function createContext(): LauncherCommandContext {
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A context for a YouTube URL target with logging at info and CLI texthooker off. */
|
||||
function createContext(
|
||||
options: {
|
||||
args?: Partial<Args>;
|
||||
plugin?: Partial<LauncherCommandContext['pluginRuntimeConfig']>;
|
||||
} = {},
|
||||
): LauncherCommandContext {
|
||||
return {
|
||||
args: makeLauncherArgs({
|
||||
target: YOUTUBE_URL,
|
||||
targetKind: 'url',
|
||||
logLevel: 'info',
|
||||
useTexthooker: false,
|
||||
...options.args,
|
||||
}),
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: makePluginConfig(options.plugin),
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: {
|
||||
@@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext {
|
||||
};
|
||||
}
|
||||
|
||||
const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const;
|
||||
|
||||
/**
|
||||
* Playback harness. Deps are inert by default and record what the tests assert on: the
|
||||
* options mpv was started with, the overlay launches, and an `events` log for the few cases
|
||||
* where relative order is the behavior.
|
||||
*/
|
||||
function makeHarness(
|
||||
options: {
|
||||
context?: LauncherCommandContext;
|
||||
deps?: Partial<PlaybackDeps>;
|
||||
events?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const context = options.context ?? createContext();
|
||||
const events = options.events ?? [];
|
||||
const startMpvOptions: StartMpvOptions[] = [];
|
||||
const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = [];
|
||||
const deps: PlaybackDeps = {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
events.push('plugin');
|
||||
},
|
||||
chooseTarget: async () => ({
|
||||
target: context.args.target,
|
||||
kind: context.args.targetKind === 'file' ? 'file' : 'url',
|
||||
}),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => {
|
||||
events.push('startMpv');
|
||||
if (startOptions) startMpvOptions.push(startOptions);
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => {
|
||||
events.push('startOverlay');
|
||||
overlayCalls.push({ extraAppArgs, configDir });
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
...options.deps,
|
||||
};
|
||||
return {
|
||||
context,
|
||||
events,
|
||||
startMpvOptions,
|
||||
overlayCalls,
|
||||
run: () => runPlaybackCommandWithDeps(context, deps),
|
||||
};
|
||||
}
|
||||
|
||||
function assertRanInOrder(events: string[], ...expected: string[]): void {
|
||||
const positions = expected.map((event) => events.indexOf(event));
|
||||
assert.ok(
|
||||
positions.every(
|
||||
(position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!),
|
||||
),
|
||||
`expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
|
||||
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
|
||||
const context = createContext();
|
||||
const registeredSignals: NodeJS.Signals[] = [];
|
||||
context.processAdapter.onSignal = (signal) => {
|
||||
@@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi
|
||||
});
|
||||
|
||||
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
||||
const calls: string[] = [];
|
||||
const context = createContext();
|
||||
context.pluginRuntimeConfig = {
|
||||
...context.pluginRuntimeConfig,
|
||||
const harness = makeHarness({
|
||||
context: createContext({
|
||||
plugin: {
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
};
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
|
||||
calls.push(`launch:${appArgs.join(' ')}`);
|
||||
},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'startMpv',
|
||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||
]);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
await harness.run();
|
||||
|
||||
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
|
||||
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]);
|
||||
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
||||
const context = createContext();
|
||||
context.pluginRuntimeConfig = {
|
||||
...context.pluginRuntimeConfig,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
const harness = makeHarness();
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
await harness.run();
|
||||
|
||||
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
|
||||
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
|
||||
| undefined;
|
||||
const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig;
|
||||
assert.equal(runtimeConfig?.autoStart, false);
|
||||
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
|
||||
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
const context = createContext({
|
||||
args: { ...FILE_TARGET, useTexthooker: true },
|
||||
plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false },
|
||||
});
|
||||
state.appPath = context.appPath ?? '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
const mpvProc = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
@@ -233,32 +187,28 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
mpvProc.killed = false;
|
||||
mpvProc.kill = () => true;
|
||||
let cleanupSawManagedOverlay = true;
|
||||
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
const harness = makeHarness({
|
||||
context,
|
||||
deps: {
|
||||
startMpv: async () => {
|
||||
setTimeout(() => {
|
||||
mpvProc.exitCode = 0;
|
||||
mpvProc.emit('exit', 0);
|
||||
}, 5);
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {
|
||||
throw new Error('startOverlay should not run when plugin auto-start is used');
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {
|
||||
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
|
||||
},
|
||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await harness.run();
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, false);
|
||||
} finally {
|
||||
state.appPath = '';
|
||||
@@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
});
|
||||
|
||||
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
calls.push('startMpv');
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
const harness = makeHarness({
|
||||
context: createContext({
|
||||
args: { ...FILE_TARGET, useTexthooker: true },
|
||||
plugin: { texthookerEnabled: true },
|
||||
}),
|
||||
deps: { isAppControlServerAvailable: async () => true },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(
|
||||
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||
?.autoStart,
|
||||
false,
|
||||
);
|
||||
await harness.run();
|
||||
|
||||
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
|
||||
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [
|
||||
'--show-visible-overlay',
|
||||
'--texthooker',
|
||||
]);
|
||||
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false);
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||
const context = createContext();
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
const originalAppData = process.env.APPDATA;
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
let overlayLoadingOsd: boolean | undefined;
|
||||
|
||||
try {
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
process.env.APPDATA = xdgConfigHome;
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
overlayLoadingOsd = (
|
||||
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
|
||||
)?.overlayLoadingOsd;
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||
overlayConfigDir = configDir;
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
const harness = makeHarness({
|
||||
context: createContext({
|
||||
args: { ...FILE_TARGET, useTexthooker: true },
|
||||
plugin: { texthookerEnabled: true },
|
||||
}),
|
||||
deps: {
|
||||
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||
availabilityConfigDir = configDir;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run());
|
||||
|
||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayLoadingOsd, true);
|
||||
assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir);
|
||||
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
|
||||
} finally {
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||
}
|
||||
if (originalAppData === undefined) {
|
||||
delete process.env.APPDATA;
|
||||
} else {
|
||||
process.env.APPDATA = originalAppData;
|
||||
}
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
const harness = makeHarness({
|
||||
context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
|
||||
deps: { isAppControlServerAvailable: async () => true },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||
await harness.run();
|
||||
|
||||
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
|
||||
});
|
||||
|
||||
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
await harness.run();
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
||||
assertRanInOrder(harness.events, 'plugin', 'startMpv');
|
||||
});
|
||||
|
||||
test('rofi playback repairs support assets before opening the picker', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '',
|
||||
targetKind: '',
|
||||
useRofi: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('assets');
|
||||
},
|
||||
const events: string[] = [];
|
||||
const harness = makeHarness({
|
||||
events,
|
||||
context: createContext({ args: { target: '', targetKind: '', useRofi: true } }),
|
||||
deps: {
|
||||
chooseTarget: async () => {
|
||||
calls.push('picker');
|
||||
events.push('picker');
|
||||
return { target: '/tmp/movie.mkv', kind: 'file' };
|
||||
},
|
||||
checkPickerDependencies: () => {},
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
|
||||
await harness.run();
|
||||
|
||||
assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
|
||||
});
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
import {
|
||||
hasLauncherExternalYomitanProfileConfig,
|
||||
loadLauncherDictionaryBackend,
|
||||
} from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: loadLauncherDictionaryBackend(),
|
||||
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
|
||||
warn: (message) => log('warn', args.logLevel, message),
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
hasLegacyMpvPlugin: () =>
|
||||
|
||||
@@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
|
||||
assert.equal(spawned.length, 1);
|
||||
});
|
||||
|
||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
||||
const spawned: string[][] = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: (_appPath, appArgs) => {
|
||||
spawned.push(appArgs);
|
||||
},
|
||||
};
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncCliTokens: [
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
],
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(spawned, [
|
||||
[
|
||||
'--sync-cli',
|
||||
'sync',
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
buildPluginRuntimeScriptOptParts,
|
||||
parsePluginRuntimeConfigFromMainConfig,
|
||||
} from './config/plugin-runtime-config.js';
|
||||
import { getDefaultSocketPath } from './types.js';
|
||||
|
||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||
@@ -18,20 +17,11 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||
secondarySub: {
|
||||
secondarySubLanguages: ['eng', true, 'deu'],
|
||||
},
|
||||
jimaku: {
|
||||
apiKey: 'abc',
|
||||
apiKeyCommand: 'pass show key',
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 8.7,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal('mode' in parsed, false);
|
||||
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
|
||||
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
|
||||
assert.equal(parsed.jimakuLanguagePreference, 'ja');
|
||||
assert.equal(parsed.jimakuMaxEntryResults, 8);
|
||||
});
|
||||
|
||||
test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => {
|
||||
@@ -207,10 +197,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
);
|
||||
});
|
||||
|
||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
||||
assert.deepEqual(
|
||||
parseLauncherMpvConfig({
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
test('launcher root help lists subcommands', () => {
|
||||
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.match(output, /Commands:/);
|
||||
assert.match(output, /jellyfin\|jf/);
|
||||
assert.match(output, /doctor/);
|
||||
assert.match(output, /config/);
|
||||
assert.match(output, /mpv/);
|
||||
assert.match(output, /logs/);
|
||||
assert.match(output, /dictionary\|dict/);
|
||||
assert.match(output, /texthooker/);
|
||||
assert.match(output, /app\|bin/);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fail } from './log.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import type {
|
||||
Args,
|
||||
LauncherLoggingConfig,
|
||||
@@ -100,6 +101,12 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLauncherDictionaryBackend(): DictionaryBackend {
|
||||
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
|
||||
? 'hachidori'
|
||||
: 'yomitan';
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
applyRootOptionsToArgs,
|
||||
createDefaultArgs,
|
||||
} from './args-normalizer.js';
|
||||
import type { CliInvocations } from './cli-parser-builder.js';
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
|
||||
@@ -19,6 +20,48 @@ function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
/** An invocations bag with nothing triggered; tests pass only the fields under test. */
|
||||
function emptyInvocations(overrides: Partial<CliInvocations> = {}): CliInvocations {
|
||||
return {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('createDefaultArgs normalizes configured language codes', () => {
|
||||
const parsed = createDefaultArgs({
|
||||
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
||||
@@ -92,7 +135,9 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
|
||||
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
jellyfinInvocation: {
|
||||
action: 'play',
|
||||
play: true,
|
||||
@@ -105,40 +150,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
action: 'show',
|
||||
logLevel: 'warn',
|
||||
},
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(parsed.jellyfin, false);
|
||||
assert.equal(parsed.jellyfinPlay, true);
|
||||
@@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(parsed.settings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
@@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
const error = withProcessExitIntercept(() => {
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
},
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
@@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
texthookerTriggered: true,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(parsed.texthookerOnly, true);
|
||||
assert.equal(parsed.texthookerOpenBrowser, true);
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
LogLevel,
|
||||
} from '../types.js';
|
||||
import {
|
||||
DEFAULT_JIMAKU_API_BASE_URL,
|
||||
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
||||
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
|
||||
} from '../types.js';
|
||||
@@ -125,11 +124,6 @@ export function createDefaultArgs(
|
||||
recursive: false,
|
||||
profile: mpvConfig.profile ?? '',
|
||||
startOverlay: false,
|
||||
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
||||
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
||||
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
||||
jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja',
|
||||
jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
@@ -184,15 +178,6 @@ export function createDefaultArgs(
|
||||
targetKind: '',
|
||||
};
|
||||
|
||||
if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey;
|
||||
if (launcherConfig.jimakuApiKeyCommand)
|
||||
parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand;
|
||||
if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
|
||||
if (launcherConfig.jimakuLanguagePreference)
|
||||
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
|
||||
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
||||
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -214,6 +199,10 @@ export function applyRootOptionsToArgs(
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
if (options.yomitan === true || options.hachidori === true) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
|
||||
}
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
|
||||
@@ -87,6 +87,8 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('--yomitan', 'Open Yomitan settings window')
|
||||
.option('--hachidori', 'Open Hachidori settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
|
||||
@@ -16,31 +16,9 @@ export function parseLauncherYoutubeSubgenConfig(
|
||||
secondarySubRaw && typeof secondarySubRaw === 'object'
|
||||
? (secondarySubRaw as Record<string, unknown>)
|
||||
: null;
|
||||
const jimakuRaw = root.jimaku;
|
||||
const jimaku =
|
||||
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
|
||||
|
||||
const jimakuLanguagePreference = jimaku?.languagePreference;
|
||||
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
|
||||
|
||||
return {
|
||||
primarySubLanguages: asStringArray(youtube?.primarySubLanguages),
|
||||
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
||||
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
||||
jimakuApiKeyCommand:
|
||||
typeof jimaku?.apiKeyCommand === 'string' ? jimaku.apiKeyCommand : undefined,
|
||||
jimakuApiBaseUrl: typeof jimaku?.apiBaseUrl === 'string' ? jimaku.apiBaseUrl : undefined,
|
||||
jimakuLanguagePreference:
|
||||
jimakuLanguagePreference === 'ja' ||
|
||||
jimakuLanguagePreference === 'en' ||
|
||||
jimakuLanguagePreference === 'none'
|
||||
? jimakuLanguagePreference
|
||||
: undefined,
|
||||
jimakuMaxEntryResults:
|
||||
typeof jimakuMaxEntryResults === 'number' &&
|
||||
Number.isFinite(jimakuMaxEntryResults) &&
|
||||
jimakuMaxEntryResults > 0
|
||||
? Math.floor(jimakuMaxEntryResults)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
+286
-102
@@ -1,83 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Args } from './types.js';
|
||||
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildForwardedJellyfinAppArgs,
|
||||
buildRootSearchGroups,
|
||||
classifyJellyfinChildSelection,
|
||||
deriveJellyfinTokenStorePath,
|
||||
hasStoredJellyfinSession,
|
||||
parseEpisodePathFromDisplay,
|
||||
parseJellyfinErrorFromAppOutput,
|
||||
parseJellyfinAppReply,
|
||||
parseJellyfinItemsReply,
|
||||
parseJellyfinLibrariesReply,
|
||||
parseJellyfinPreviewAuthResponse,
|
||||
runJellyfinPlayMenuWithDeps,
|
||||
shouldRetryWithStartForNoRunningInstance,
|
||||
} from './jellyfin.js';
|
||||
import { makeLauncherArgs } from './test-support/args.js';
|
||||
import { withEnv } from './test-support/env.js';
|
||||
|
||||
function createArgs(): Args {
|
||||
return {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
youtubeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: 'https://jellyfin.example.test',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
launchMode: 'normal',
|
||||
};
|
||||
}
|
||||
class HandoffComplete extends Error {}
|
||||
|
||||
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
||||
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
||||
const calls: string[] = [];
|
||||
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
createArgs(),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
/**
|
||||
* Runs the play menu with fake deps and a direct env session. The app handoff never returns in
|
||||
* production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
|
||||
*/
|
||||
async function runPlayMenu(options: {
|
||||
socketReady?: boolean;
|
||||
overrides?: Partial<PlayMenuDeps>;
|
||||
args?: Parameters<typeof makeLauncherArgs>[0];
|
||||
}) {
|
||||
const launches: string[] = [];
|
||||
const handoff: { appArgs: string[] | null } = { appArgs: null };
|
||||
const deps: PlayMenuDeps = {
|
||||
loadLauncherJellyfinConfig: () => ({}),
|
||||
findRofiTheme: () => null,
|
||||
resolveJellyfinSelection: async () => 'item-123',
|
||||
@@ -87,42 +44,269 @@ test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bo
|
||||
hasStoredJellyfinSession: () => true,
|
||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
pathExists: () => false,
|
||||
pathExists: () => options.socketReady ?? false,
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => {
|
||||
calls.push('wait');
|
||||
return true;
|
||||
launches.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
launchMpvIdleDetached: async () => {
|
||||
calls.push('launch');
|
||||
launches.push('launch');
|
||||
},
|
||||
resolveLauncherRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
runAppCommandWithInheritLogged: (_appPath, appArgs) => {
|
||||
handoff.appArgs = appArgs;
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
runAppCommandWithInheritLogged: () => {
|
||||
calls.push('handoff');
|
||||
throw new Error('stop after handoff');
|
||||
isAppManagedByLauncher: () => false,
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected owned-app handoff');
|
||||
},
|
||||
waitForUnixSocketClosed: async () => {
|
||||
throw new Error('unexpected wait for mpv to close');
|
||||
},
|
||||
exitProcess: () => {
|
||||
throw new Error('unexpected exit');
|
||||
},
|
||||
log: () => {},
|
||||
},
|
||||
...options.overrides,
|
||||
};
|
||||
|
||||
await withEnv(
|
||||
{ SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
|
||||
() =>
|
||||
assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
deps,
|
||||
),
|
||||
HandoffComplete,
|
||||
),
|
||||
/stop after handoff/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
||||
} finally {
|
||||
if (originalAccessToken === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
||||
}
|
||||
if (originalUserId === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
||||
}
|
||||
return { launches, appArgs: handoff.appArgs };
|
||||
}
|
||||
|
||||
test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
|
||||
const { launches, appArgs } = await runPlayMenu({});
|
||||
|
||||
assert.deepEqual(launches, ['plugin', 'launch']);
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
|
||||
const { launches, appArgs } = await runPlayMenu({ socketReady: true });
|
||||
|
||||
assert.deepEqual(launches, []);
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
test('Jellyfin playback in an app the picker started waits for mpv to close before exiting', async () => {
|
||||
const events: string[] = [];
|
||||
const { appArgs } = await runPlayMenu({
|
||||
socketReady: true,
|
||||
overrides: {
|
||||
isAppManagedByLauncher: () => true,
|
||||
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
||||
events.push(`handoff ${appArgs.join(' ')}`);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
waitForUnixSocketClosed: async (socketPath) => {
|
||||
events.push(`wait ${socketPath}`);
|
||||
},
|
||||
exitProcess: (code) => {
|
||||
events.push(`exit ${code}`);
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(appArgs, null);
|
||||
assert.deepEqual(events, [
|
||||
'handoff --start --jellyfin-play --jellyfin-item-id=item-123',
|
||||
'wait /tmp/subminer.sock',
|
||||
'exit 0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinAppReply maps library and item replies to picker entries', () => {
|
||||
const libraries = parseJellyfinAppReply(
|
||||
JSON.stringify({
|
||||
libraries: [
|
||||
{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' },
|
||||
{ id: 'lib1', name: 'Duplicate', collectionType: 'tvshows' },
|
||||
{ name: 'No id' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.ok(libraries?.ok);
|
||||
assert.deepEqual(parseJellyfinLibrariesReply(libraries.payload), [
|
||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||
]);
|
||||
|
||||
const items = parseJellyfinAppReply(
|
||||
JSON.stringify({ items: [{ id: 'movie-1', title: 'Movie [Alt]', type: 'Movie' }] }),
|
||||
);
|
||||
assert.ok(items?.ok);
|
||||
assert.deepEqual(parseJellyfinItemsReply(items.payload), [
|
||||
{ id: 'movie-1', name: 'Movie [Alt]', type: 'Movie', display: 'Movie [Alt]' },
|
||||
]);
|
||||
assert.equal(parseJellyfinLibrariesReply(items.payload), null);
|
||||
});
|
||||
|
||||
test('parseJellyfinAppReply surfaces app errors and rejects partial files', () => {
|
||||
assert.deepEqual(
|
||||
parseJellyfinAppReply(
|
||||
JSON.stringify({ error: 'Missing Jellyfin session. Run --jellyfin-login first.' }),
|
||||
),
|
||||
{ ok: false, error: 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.' },
|
||||
);
|
||||
assert.equal(parseJellyfinAppReply(''), null);
|
||||
assert.equal(parseJellyfinAppReply('{"libraries": ['), null);
|
||||
});
|
||||
|
||||
test('buildForwardedJellyfinAppArgs appends server, password store, and log level', () => {
|
||||
const forwarded = buildForwardedJellyfinAppArgs(
|
||||
{
|
||||
jellyfinServer: 'https://jf.example.test/',
|
||||
passwordStore: 'gnome-libsecret',
|
||||
logLevel: 'info',
|
||||
} as never,
|
||||
['--jellyfin-libraries'],
|
||||
);
|
||||
|
||||
assert.deepEqual(forwarded, [
|
||||
'--jellyfin-libraries',
|
||||
'--jellyfin-server',
|
||||
'https://jf.example.test',
|
||||
'--password-store',
|
||||
'gnome-libsecret',
|
||||
'--log-level',
|
||||
'info',
|
||||
]);
|
||||
});
|
||||
|
||||
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({
|
||||
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 without a token', () => {
|
||||
assert.equal(
|
||||
parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasStoredJellyfinSession checks token-store existence', () => {
|
||||
const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
|
||||
const tokenPath = deriveJellyfinTokenStorePath(configPath);
|
||||
const exists = (candidate: string): boolean => candidate === tokenPath;
|
||||
assert.equal(hasStoredJellyfinSession(configPath, exists), true);
|
||||
assert.equal(
|
||||
hasStoredJellyfinSession(path.join('/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('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',
|
||||
});
|
||||
});
|
||||
+196
-223
@@ -10,7 +10,8 @@ import type {
|
||||
JellyfinGroupEntry,
|
||||
} from './types.js';
|
||||
import { shouldForwardLogLevel } from './types.js';
|
||||
import { log, fail, getMpvLogPath } from './log.js';
|
||||
import type { JellyfinPreviewAuthPayload } from '../src/shared/jellyfin-cli-response.js';
|
||||
import { log, fail } from './log.js';
|
||||
import { nowMs } from './time.js';
|
||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||
import {
|
||||
@@ -23,11 +24,14 @@ import {
|
||||
import { loadLauncherJellyfinConfig } from './config.js';
|
||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
||||
import {
|
||||
adoptLauncherStartedApp,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandWithInheritLogged,
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketClosed,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||
@@ -52,6 +56,10 @@ type JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||
isAppManagedByLauncher: typeof isAppManagedByLauncher;
|
||||
runAppCommandCaptureOutput: typeof runAppCommandCaptureOutput;
|
||||
waitForUnixSocketClosed: typeof waitForUnixSocketClosed;
|
||||
exitProcess: (code: number) => never;
|
||||
log: typeof log;
|
||||
};
|
||||
|
||||
@@ -75,6 +83,10 @@ const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
runAppCommandWithInheritLogged,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandCaptureOutput,
|
||||
waitForUnixSocketClosed,
|
||||
exitProcess: (code) => process.exit(code),
|
||||
log,
|
||||
};
|
||||
|
||||
@@ -171,74 +183,75 @@ 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;
|
||||
// Reply the app writes to --jellyfin-response-path; see src/shared/jellyfin-cli-response.ts.
|
||||
export type JellyfinAppReply =
|
||||
| { ok: true; payload: Record<string, unknown> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
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 };
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
|
||||
function readString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function parseJellyfinAppReply(raw: string): JellyfinAppReply | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) return null;
|
||||
const error = readString(parsed, 'error');
|
||||
if (error) return { ok: false, error: normalizeJellyfinAppError(error) };
|
||||
return { ok: true, payload: parsed };
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesReply(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinLibraryEntry[] | null {
|
||||
if (!Array.isArray(payload.libraries)) return null;
|
||||
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);
|
||||
for (const entry of payload.libraries) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = readString(entry, 'id');
|
||||
if (!id || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
libraries.push({
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
kind: parsed.type,
|
||||
id,
|
||||
name: readString(entry, 'name') || 'Untitled',
|
||||
kind: readString(entry, 'collectionType') || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
|
||||
export function parseJellyfinItemsReply(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinItemEntry[] | null {
|
||||
if (!Array.isArray(payload.items)) return null;
|
||||
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,
|
||||
});
|
||||
for (const entry of payload.items) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = readString(entry, 'id');
|
||||
if (!id || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
const title = readString(entry, 'title') || 'Untitled';
|
||||
items.push({ id, name: title, type: readString(entry, 'type'), display: title });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
return items;
|
||||
function normalizeJellyfinAppError(message: string): string {
|
||||
if (message.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||
@@ -260,41 +273,20 @@ export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||
}
|
||||
|
||||
if (line.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
return normalizeJellyfinAppError(line);
|
||||
}
|
||||
}
|
||||
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() : '';
|
||||
export function parseJellyfinPreviewAuthResponse(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinPreviewAuthPayload | null {
|
||||
const serverUrl = sanitizeServerUrl(readString(payload, 'serverUrl'));
|
||||
const accessToken = readString(payload, 'accessToken');
|
||||
const userId = readString(payload, 'userId');
|
||||
if (!serverUrl || !accessToken) return null;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
};
|
||||
return { serverUrl, accessToken, userId };
|
||||
}
|
||||
|
||||
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
||||
@@ -312,19 +304,6 @@ export function hasStoredJellyfinSession(
|
||||
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 {
|
||||
@@ -421,145 +400,79 @@ export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): st
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const JELLYFIN_APP_REPLY_TIMEOUT_MS = 60_000;
|
||||
const JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function runAppJellyfinCommand(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
||||
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
||||
|
||||
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;
|
||||
function readJellyfinAppReply(responsePath: string): JellyfinAppReply | null {
|
||||
try {
|
||||
if (fs.existsSync(logPath)) {
|
||||
logOffset = fs.statSync(logPath).size;
|
||||
}
|
||||
return parseJellyfinAppReply(fs.readFileSync(responsePath, 'utf8'));
|
||||
} catch {
|
||||
logOffset = 0;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs a Jellyfin command in the app and waits for the reply it writes to a temp file. The helper
|
||||
// process usually hands the command to an already-running instance and exits at once with no
|
||||
// output, so the reply file is the only result channel that works in both cases.
|
||||
async function requestJellyfinReplyFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
): Promise<JellyfinAppReply> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-reply-'));
|
||||
const responsePath = path.join(tmpDir, 'response.json');
|
||||
const forwarded = buildForwardedJellyfinAppArgs(args, [
|
||||
...appArgs,
|
||||
`--jellyfin-response-path=${responsePath}`,
|
||||
]);
|
||||
|
||||
const runOnce = (): { status: number; output: string; error: string } => {
|
||||
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 };
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) };
|
||||
};
|
||||
|
||||
let retriedAfterStart = false;
|
||||
try {
|
||||
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 = nowMs() + settleWindowMs;
|
||||
const settleOffset = attempt.logOffset;
|
||||
while (nowMs() < 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',
|
||||
// Start it the way `subminer app` does: the app outlives this picker and the playback, so it
|
||||
// needs its tray to be visible and quittable.
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
`${label}: starting app in the background, then retrying command`,
|
||||
);
|
||||
if (attempt.status !== 0 || attempt.error) {
|
||||
return null;
|
||||
launchAppBackgroundDetached(appPath, args.logLevel);
|
||||
adoptLauncherStartedApp(appPath, args);
|
||||
// A cold start (an AppImage mount, first-run setup) can take a while to claim the instance
|
||||
// lock, so keep retrying until the app takes the command or the request deadline passes.
|
||||
const startDeadline = nowMs() + timeoutMs;
|
||||
do {
|
||||
await sleep(1000);
|
||||
attempt = runOnce();
|
||||
} while (shouldRetryWithStartForNoRunningInstance(attempt.error) && nowMs() < startDeadline);
|
||||
}
|
||||
// A written reply is authoritative; the exit status and log lines only explain a missing one.
|
||||
const earlyReply = readJellyfinAppReply(responsePath);
|
||||
if (earlyReply) return earlyReply;
|
||||
if (attempt.status !== 0) {
|
||||
return { ok: false, error: attempt.error || attempt.output.trim() || `${label} failed.` };
|
||||
}
|
||||
if (attempt.error) {
|
||||
return { ok: false, error: attempt.error };
|
||||
}
|
||||
|
||||
const deadline = nowMs() + 4000;
|
||||
const deadline = nowMs() + timeoutMs;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
||||
const parsed = parseJellyfinPreviewAuthResponse(raw);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
const reply = readJellyfinAppReply(responsePath);
|
||||
if (reply) return reply;
|
||||
await sleep(100);
|
||||
}
|
||||
return null;
|
||||
return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` };
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
@@ -569,19 +482,57 @@ async function requestJellyfinPreviewAuthFromApp(
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJellyfinPreviewAuthFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
): Promise<JellyfinPreviewAuthPayload | null> {
|
||||
const reply = await requestJellyfinReplyFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-preview-auth'],
|
||||
'jellyfin-preview-auth',
|
||||
JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
log('debug', args.logLevel, `jellyfin-preview-auth: ${reply.error}`);
|
||||
return null;
|
||||
}
|
||||
return parseJellyfinPreviewAuthResponse(reply.payload);
|
||||
}
|
||||
|
||||
async function requestJellyfinListFromApp<T>(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
parse: (payload: Record<string, unknown>) => T[] | null,
|
||||
): Promise<T[]> {
|
||||
const reply = await requestJellyfinReplyFromApp(
|
||||
appPath,
|
||||
args,
|
||||
appArgs,
|
||||
label,
|
||||
JELLYFIN_APP_REPLY_TIMEOUT_MS,
|
||||
);
|
||||
if (!reply.ok) fail(reply.error);
|
||||
const entries = parse(reply.payload);
|
||||
if (!entries) fail(`Unexpected reply from SubMiner for ${label}.`);
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveJellyfinSelectionViaApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
session: JellyfinSessionConfig,
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
const listLibrariesOutput = await runAppJellyfinListCommand(
|
||||
const libraries = await requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-libraries'],
|
||||
'jellyfin-libraries',
|
||||
parseJellyfinLibrariesReply,
|
||||
);
|
||||
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
|
||||
if (libraries.length === 0) {
|
||||
fail('No Jellyfin libraries found.');
|
||||
}
|
||||
@@ -655,8 +606,13 @@ async function resolveJellyfinSelectionViaApp(
|
||||
if (includeItemTypes) {
|
||||
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
||||
}
|
||||
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
|
||||
return parseJellyfinItemsFromAppOutput(output);
|
||||
return requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
itemArgs,
|
||||
'jellyfin-items',
|
||||
parseJellyfinItemsReply,
|
||||
);
|
||||
};
|
||||
|
||||
let rootItems =
|
||||
@@ -1106,5 +1062,22 @@ export async function runJellyfinPlayMenuWithDeps(
|
||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
if (!deps.isAppManagedByLauncher()) {
|
||||
// A running app keeps running after playback; a cold `--start --jellyfin-play` makes the app
|
||||
// quit with mpv on its own.
|
||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
}
|
||||
// The picker started the app, so this playback owns it, as with local playback: hand over the
|
||||
// item, wait for mpv to close, then exit, which stops the app.
|
||||
const handoff = deps.runAppCommandCaptureOutput(appPath, forwarded);
|
||||
if (handoff.status !== 0) {
|
||||
fail(handoff.error?.message || handoff.stderr.trim() || 'Failed to start Jellyfin playback.');
|
||||
}
|
||||
deps.log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'jellyfin-play: waiting for mpv to close before stopping the app',
|
||||
);
|
||||
await deps.waitForUnixSocketClosed(mpvSocketPath);
|
||||
return deps.exitProcess(0);
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { Args, JimakuLanguagePreference } from './types.js';
|
||||
import { DEFAULT_JIMAKU_API_BASE_URL } from './types.js';
|
||||
import { commandExists } from './util.js';
|
||||
|
||||
export interface JimakuEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
english_name?: string | null;
|
||||
japanese_name?: string | null;
|
||||
flags?: {
|
||||
anime?: boolean;
|
||||
movie?: boolean;
|
||||
adult?: boolean;
|
||||
external?: boolean;
|
||||
unverified?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface JimakuFileEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
last_modified: string;
|
||||
}
|
||||
|
||||
interface JimakuApiError {
|
||||
error: string;
|
||||
code?: number;
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
type JimakuApiResponse<T> = { ok: true; data: T } | { ok: false; error: JimakuApiError };
|
||||
|
||||
type JimakuDownloadResult = { ok: true; path: string } | { ok: false; error: JimakuApiError };
|
||||
|
||||
interface JimakuConfig {
|
||||
apiKey: string;
|
||||
apiKeyCommand: string;
|
||||
apiBaseUrl: string;
|
||||
languagePreference: JimakuLanguagePreference;
|
||||
maxEntryResults: number;
|
||||
}
|
||||
|
||||
interface JimakuMediaInfo {
|
||||
title: string;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
filename: string;
|
||||
rawTitle: string;
|
||||
}
|
||||
|
||||
function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined {
|
||||
const value = headers['x-ratelimit-reset-after'];
|
||||
if (!value) return undefined;
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (!raw) return undefined;
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function matchEpisodeFromName(name: string): {
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
index: number | null;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
} {
|
||||
const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
|
||||
if (seasonEpisode && seasonEpisode.index !== undefined) {
|
||||
const seasonText = seasonEpisode[1];
|
||||
const episodeText = seasonEpisode[2];
|
||||
if (!seasonText || !episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: Number.parseInt(seasonText, 10),
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: seasonEpisode.index,
|
||||
confidence: 'high',
|
||||
};
|
||||
}
|
||||
|
||||
const alt = name.match(/(\d{1,2})x(\d{1,3})/i);
|
||||
if (alt && alt.index !== undefined) {
|
||||
const seasonText = alt[1];
|
||||
const episodeText = alt[2];
|
||||
if (!seasonText || !episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: Number.parseInt(seasonText, 10),
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: alt.index,
|
||||
confidence: 'high',
|
||||
};
|
||||
}
|
||||
|
||||
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
|
||||
if (epOnly && epOnly.index !== undefined) {
|
||||
const episodeText = epOnly[1];
|
||||
if (!episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: null,
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: epOnly.index,
|
||||
confidence: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/);
|
||||
if (numeric && numeric.index !== undefined) {
|
||||
const episodeText = numeric[1];
|
||||
if (!episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: null,
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: numeric.index,
|
||||
confidence: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
|
||||
function detectSeasonFromDir(mediaPath: string): number | null {
|
||||
const parent = path.basename(path.dirname(mediaPath));
|
||||
const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i);
|
||||
if (!match) return null;
|
||||
const seasonText = match[1];
|
||||
if (!seasonText) return null;
|
||||
const parsed = Number.parseInt(seasonText, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseGuessitOutput(mediaPath: string, stdout: string): JimakuMediaInfo | null {
|
||||
const payload = stdout.trim();
|
||||
if (!payload) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as {
|
||||
title?: string;
|
||||
title_original?: string;
|
||||
series?: string;
|
||||
season?: number | string;
|
||||
episode?: number | string;
|
||||
episode_list?: Array<number | string>;
|
||||
};
|
||||
const season =
|
||||
typeof parsed.season === 'number'
|
||||
? parsed.season
|
||||
: typeof parsed.season === 'string'
|
||||
? Number.parseInt(parsed.season, 10)
|
||||
: null;
|
||||
const directEpisode =
|
||||
typeof parsed.episode === 'number'
|
||||
? parsed.episode
|
||||
: typeof parsed.episode === 'string'
|
||||
? Number.parseInt(parsed.episode, 10)
|
||||
: null;
|
||||
const episodeFromList =
|
||||
parsed.episode_list && parsed.episode_list.length > 0
|
||||
? Number.parseInt(String(parsed.episode_list[0]), 10)
|
||||
: null;
|
||||
const episodeValue =
|
||||
directEpisode !== null && Number.isFinite(directEpisode) ? directEpisode : episodeFromList;
|
||||
const episode = Number.isFinite(episodeValue as number) ? (episodeValue as number) : null;
|
||||
const title = (parsed.title || parsed.title_original || parsed.series || '').trim();
|
||||
const hasStructuredData =
|
||||
title.length > 0 ||
|
||||
Number.isFinite(season as number) ||
|
||||
Number.isFinite(episodeValue as number);
|
||||
|
||||
if (!hasStructuredData) return null;
|
||||
|
||||
return {
|
||||
title: title || '',
|
||||
season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath),
|
||||
episode: episode,
|
||||
confidence: 'high',
|
||||
filename: path.basename(mediaPath),
|
||||
rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null {
|
||||
if (!commandExists('guessit')) return null;
|
||||
|
||||
try {
|
||||
const fileName = path.basename(mediaPath);
|
||||
const result = spawnSync('guessit', ['--json', fileName], {
|
||||
cwd: path.dirname(mediaPath),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 2_000_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return parseGuessitOutput(mediaPath, result.stdout || '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupTitle(value: string): string {
|
||||
return value
|
||||
.replace(/^[\s-–—]+/, '')
|
||||
.replace(/[\s-–—]+$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatLangScore(name: string, pref: JimakuLanguagePreference): number {
|
||||
if (pref === 'none') return 0;
|
||||
const upper = name.toUpperCase();
|
||||
const hasJa =
|
||||
/(^|[\W_])JA([\W_]|$)/.test(upper) ||
|
||||
/(^|[\W_])JPN([\W_]|$)/.test(upper) ||
|
||||
upper.includes('.JA.');
|
||||
const hasEn =
|
||||
/(^|[\W_])EN([\W_]|$)/.test(upper) ||
|
||||
/(^|[\W_])ENG([\W_]|$)/.test(upper) ||
|
||||
upper.includes('.EN.');
|
||||
if (pref === 'ja') {
|
||||
if (hasJa) return 2;
|
||||
if (hasEn) return 1;
|
||||
} else if (pref === 'en') {
|
||||
if (hasEn) return 2;
|
||||
if (hasJa) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function resolveJimakuApiKey(config: JimakuConfig): Promise<string | null> {
|
||||
if (config.apiKey && config.apiKey.trim()) {
|
||||
return config.apiKey.trim();
|
||||
}
|
||||
if (config.apiKeyCommand && config.apiKeyCommand.trim()) {
|
||||
try {
|
||||
const commandResult = spawnSync(config.apiKeyCommand, {
|
||||
shell: true,
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
});
|
||||
if (commandResult.error) return null;
|
||||
const key = (commandResult.stdout || '').trim();
|
||||
return key.length > 0 ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function jimakuFetchJson<T>(
|
||||
endpoint: string,
|
||||
query: Record<string, string | number | boolean | null | undefined>,
|
||||
options: { baseUrl: string; apiKey: string },
|
||||
): Promise<JimakuApiResponse<T>> {
|
||||
const url = new URL(endpoint, options.baseUrl);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const requestUrl = new URL(url.toString());
|
||||
const transport = requestUrl.protocol === 'https:' ? https : http;
|
||||
const req = transport.request(
|
||||
requestUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: options.apiKey,
|
||||
'User-Agent': 'SubMiner',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
res.on('end', () => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 200 && status < 300) {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as T;
|
||||
resolve({ ok: true, data: parsed });
|
||||
} catch {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { error: 'Failed to parse Jimaku response JSON.' },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let errorMessage = `Jimaku API error (HTTP ${status})`;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { error?: string };
|
||||
if (parsed && parsed.error) {
|
||||
errorMessage = parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
error: errorMessage,
|
||||
code: status || undefined,
|
||||
retryAfter: status === 429 ? getRetryAfter(res.headers) : undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (error) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { error: `Jimaku request failed: ${(error as Error).message}` },
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
|
||||
if (!mediaPath) {
|
||||
return {
|
||||
title: '',
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: 'low',
|
||||
filename: '',
|
||||
rawTitle: '',
|
||||
};
|
||||
}
|
||||
|
||||
const guessitInfo = parseMediaInfoWithGuessit(mediaPath);
|
||||
if (guessitInfo) return guessitInfo;
|
||||
|
||||
const filename = path.basename(mediaPath);
|
||||
let name = filename.replace(/\.[^/.]+$/, '');
|
||||
name = name.replace(/\[[^\]]*]/g, ' ');
|
||||
name = name.replace(/\(\d{4}\)/g, ' ');
|
||||
name = name.replace(/[._]/g, ' ');
|
||||
name = name.replace(/[–—]/g, '-');
|
||||
name = name.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const parsed = matchEpisodeFromName(name);
|
||||
let titlePart = name;
|
||||
if (parsed.index !== null) {
|
||||
titlePart = name.slice(0, parsed.index);
|
||||
}
|
||||
|
||||
const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath);
|
||||
const title = cleanupTitle(titlePart || name);
|
||||
|
||||
return {
|
||||
title,
|
||||
season: seasonFromDir,
|
||||
episode: parsed.episode,
|
||||
confidence: parsed.confidence,
|
||||
filename,
|
||||
rawTitle: name,
|
||||
};
|
||||
}
|
||||
|
||||
export function sortJimakuFiles(
|
||||
files: JimakuFileEntry[],
|
||||
pref: JimakuLanguagePreference,
|
||||
): JimakuFileEntry[] {
|
||||
if (pref === 'none') return files;
|
||||
return [...files].sort((a, b) => {
|
||||
const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadToFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers: Record<string, string>,
|
||||
redirectCount = 0,
|
||||
): Promise<JimakuDownloadResult> {
|
||||
if (redirectCount > 3) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Too many redirects while downloading subtitle.' },
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = transport.get(parsedUrl, { headers }, (res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, parsedUrl).toString();
|
||||
res.resume();
|
||||
downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
res.resume();
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
error: `Failed to download subtitle (HTTP ${status}).`,
|
||||
code: status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
res.pipe(fileStream);
|
||||
fileStream.on('finish', () => {
|
||||
fileStream.close(() => {
|
||||
resolve({ ok: true, path: destPath });
|
||||
});
|
||||
});
|
||||
fileStream.on('error', (err: Error) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { error: `Failed to save subtitle: ${err.message}` },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
error: `Download request failed: ${(err as Error).message}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidSubtitleCandidateFile(filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return ext === '.srt' || ext === '.vtt' || ext === '.ass' || ext === '.ssa' || ext === '.sub';
|
||||
}
|
||||
|
||||
export function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] {
|
||||
if (preference === 'en') return ['en', 'eng'];
|
||||
if (preference === 'none') return [];
|
||||
return ['ja', 'jpn'];
|
||||
}
|
||||
|
||||
export function normalizeJimakuSearchInput(mediaPath: string): string {
|
||||
const trimmed = (mediaPath || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (!/^https?:\/\/.*/.test(trimmed)) return trimmed;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const titleParam =
|
||||
url.searchParams.get('title') || url.searchParams.get('name') || url.searchParams.get('q');
|
||||
if (titleParam && titleParam.trim()) return titleParam.trim();
|
||||
|
||||
const pathParts = url.pathname.split('/').filter(Boolean).reverse();
|
||||
const candidate = pathParts.find((part) => {
|
||||
const decoded = decodeURIComponent(part || '').replace(/\.[^/.]+$/, '');
|
||||
const lowered = decoded.toLowerCase();
|
||||
return lowered.length > 2 && !/^[0-9.]+$/.test(lowered) && !/^[a-f0-9]{16,}$/i.test(lowered);
|
||||
});
|
||||
|
||||
const fallback = candidate || url.hostname.replace(/^www\./, '');
|
||||
return sanitizeJimakuQueryInput(decodeURIComponent(fallback));
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeJimakuQueryInput(value: string): string {
|
||||
return value
|
||||
.replace(/^\s*-\s*/, '')
|
||||
.replace(/[^\w\s\-'".:(),]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function buildJimakuConfig(args: Args): {
|
||||
apiKey: string;
|
||||
apiKeyCommand: string;
|
||||
apiBaseUrl: string;
|
||||
languagePreference: JimakuLanguagePreference;
|
||||
maxEntryResults: number;
|
||||
} {
|
||||
return {
|
||||
apiKey: args.jimakuApiKey,
|
||||
apiKeyCommand: args.jimakuApiKeyCommand,
|
||||
apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL,
|
||||
languagePreference: args.jimakuLanguagePreference,
|
||||
maxEntryResults: args.jimakuMaxEntryResults || 10,
|
||||
};
|
||||
}
|
||||
+413
-937
File diff suppressed because it is too large.
Load diff
+434
-875
File diff suppressed because it is too large.
Load diff
@@ -1121,6 +1121,21 @@ function clearOverlayManagedByLauncher(): void {
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
|
||||
// For commands that start the app ahead of playback (the Jellyfin picker): the launcher now owns
|
||||
// it, as with local playback, and stops it when the launcher exits for any reason, including a
|
||||
// cancelled picker or Ctrl+C.
|
||||
export function adoptLauncherStartedApp(appPath: string, args: Args): void {
|
||||
if (state.overlayManagedByLauncher) return;
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
process.once('exit', () => stopManagedOverlayApp(args));
|
||||
process.once('SIGINT', () => process.exit(130));
|
||||
process.once('SIGTERM', () => process.exit(143));
|
||||
}
|
||||
|
||||
export function isAppManagedByLauncher(): boolean {
|
||||
return state.overlayManagedByLauncher;
|
||||
}
|
||||
|
||||
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
||||
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
||||
if (result.error) {
|
||||
@@ -1723,6 +1738,13 @@ export async function canConnectUnixSocket(socketPath: string): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves once mpv stops accepting connections on its IPC socket, i.e. the player was closed.
|
||||
export async function waitForUnixSocketClosed(socketPath: string, pollMs = 1000): Promise<void> {
|
||||
while (await canConnectUnixSocket(socketPath)) {
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForUnixSocketReady(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
|
||||
@@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => {
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs appends CLI mpv profile to configured mpv profile', () => {
|
||||
const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' });
|
||||
|
||||
assert.equal(parsed.profile, 'anime,hdr');
|
||||
});
|
||||
|
||||
test('parseArgs maps root settings window option', () => {
|
||||
const parsed = parseArgs(['--settings'], 'subminer', {});
|
||||
|
||||
@@ -224,14 +218,6 @@ test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps lifetime stats cleanup flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
|
||||
|
||||
@@ -353,3 +339,12 @@ test('parseArgs requires an explicit logs action', () => {
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Logs command requires -e or --export/);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`parseArgs forwards ${flag} to the app`, () => {
|
||||
const parsed = parseArgs([flag], 'subminer', {});
|
||||
assert.equal(parsed.appPassthrough, true);
|
||||
assert.deepEqual(parsed.appArgs, [flag]);
|
||||
assert.equal(parsed.settings, false);
|
||||
});
|
||||
}
|
||||
+19
-3
@@ -119,11 +119,27 @@ export async function promptOptionalJellyfinSearch(
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
if (useRofi && commandExists('rofi')) {
|
||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
||||
// Input-only prompt: the shipped theme pins a tall window and has no message widget, so
|
||||
// drop the listview (an empty row there reads as "no results") and show the hint instead.
|
||||
const inputOnlyTheme =
|
||||
'window { height: 0; } mainbox { children: [inputbar, message]; } ' +
|
||||
'message { background-color: transparent; padding: 10px 18px 4px 18px; } ' +
|
||||
'textbox { background-color: transparent; }';
|
||||
const rofiArgs = [
|
||||
'-dmenu',
|
||||
'-i',
|
||||
'-p',
|
||||
formatRofiPrompt('Jellyfin Search (optional)'),
|
||||
'-mesg',
|
||||
'Type to search, or press Enter to browse all',
|
||||
];
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
rofiArgs.push('-theme', themePath, '-theme-str', inputOnlyTheme);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
rofiArgs.push(
|
||||
'-theme-str',
|
||||
`configuration { font: "Noto Sans CJK JP Regular 8";} ${inputOnlyTheme}`,
|
||||
);
|
||||
}
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: '\n',
|
||||
|
||||
@@ -8,282 +8,168 @@ import {
|
||||
installManagedPluginAssetsViaApp,
|
||||
} from './runtime-plugin-preflight';
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||
const calls: string[] = [];
|
||||
type EnsureOptions = Parameters<typeof ensureLinuxRuntimePluginAvailable>[0];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'darwin',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return true;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
let themeAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return themeAvailable;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
themeAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'detect',
|
||||
'resolve',
|
||||
'theme',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'resolve',
|
||||
'theme',
|
||||
'thumbnailer',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
let thumbnailerAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => true,
|
||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
thumbnailerAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (_level, _configured, message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'install',
|
||||
'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
||||
const calls: string[] = [];
|
||||
let thumbnailerAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => true,
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
thumbnailerAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['install']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveCount = 0;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
/**
|
||||
* Linux preflight harness: by default every managed asset resolves, so tests override only
|
||||
* the probes that should report something missing. Counts installs and collects log messages.
|
||||
*/
|
||||
function makeEnsureHarness(overrides: Partial<EnsureOptions> = {}) {
|
||||
const state = { installs: 0, logs: [] as string[] };
|
||||
const options: EnsureOptions = {
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => {
|
||||
resolveCount += 1;
|
||||
calls.push(`resolve:${resolveCount}`);
|
||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||
},
|
||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => true,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
state.installs += 1;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
log: (_level, _configured, message) => {
|
||||
state.logs.push(message);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { state, run: () => ensureLinuxRuntimePluginAvailable(options) };
|
||||
}
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||
const unexpected = () => {
|
||||
throw new Error('probed on a non-Linux platform');
|
||||
};
|
||||
const harness = makeEnsureHarness({
|
||||
platform: 'darwin',
|
||||
detectInstalledPlugin: unexpected,
|
||||
resolveRuntimePluginPath: unexpected,
|
||||
isManagedThemeAvailable: unexpected,
|
||||
isManagedThumbnailerAvailable: unexpected,
|
||||
});
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 0);
|
||||
assert.deepEqual(harness.state.logs, []);
|
||||
});
|
||||
|
||||
const skipInstallCases: Array<{ name: string; overrides: Partial<EnsureOptions> }> = [
|
||||
{
|
||||
name: 'plugin, theme, and thumbnailer exist',
|
||||
overrides: { detectInstalledPlugin: () => true, resolveRuntimePluginPath: () => null },
|
||||
},
|
||||
{ name: 'all managed assets resolve', overrides: {} },
|
||||
];
|
||||
|
||||
for (const c of skipInstallCases) {
|
||||
test(`ensureLinuxRuntimePluginAvailable skips install when ${c.name}`, async () => {
|
||||
const harness = makeEnsureHarness(c.overrides);
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 0);
|
||||
assert.deepEqual(harness.state.logs, []);
|
||||
});
|
||||
}
|
||||
|
||||
const installWhenMissingCases: Array<{ name: string; missing: 'theme' | 'thumbnailer' }> = [
|
||||
{ name: 'rofi theme is missing', missing: 'theme' },
|
||||
{ name: 'thumbnailer is missing', missing: 'thumbnailer' },
|
||||
];
|
||||
|
||||
for (const c of installWhenMissingCases) {
|
||||
test(`ensureLinuxRuntimePluginAvailable installs managed assets when ${c.name}`, async () => {
|
||||
let available = false;
|
||||
const probe = {
|
||||
[c.missing === 'theme' ? 'isManagedThemeAvailable' : 'isManagedThumbnailerAvailable']: () =>
|
||||
available,
|
||||
};
|
||||
const harness = makeEnsureHarness({
|
||||
...probe,
|
||||
installManagedPluginAssets: async () => {
|
||||
harness.state.installs += 1;
|
||||
available = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'resolve:1',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'resolve:2',
|
||||
]);
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 1);
|
||||
assert.equal(harness.state.logs.length, 2);
|
||||
assert.match(harness.state.logs[0]!, /support assets missing; installing/);
|
||||
assert.match(
|
||||
harness.state.logs[1]!,
|
||||
/installed: plugin=\/tmp\/plugin\/main\.lua theme=.*subminer\.rasi thumbnailer=.*\.thumbnailer/,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
||||
let thumbnailerAvailable = false;
|
||||
const harness = makeEnsureHarness({
|
||||
detectInstalledPlugin: () => true,
|
||||
// An installed plugin means the bundled path is never needed, even after the install.
|
||||
resolveRuntimePluginPath: () => null,
|
||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||
installManagedPluginAssets: async () => {
|
||||
harness.state.installs += 1;
|
||||
thumbnailerAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => false,
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 1);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||
let installed = false;
|
||||
const harness = makeEnsureHarness({
|
||||
resolveRuntimePluginPath: () => (installed ? '/tmp/plugin/main.lua' : null),
|
||||
installManagedPluginAssets: async () => {
|
||||
harness.state.installs += 1;
|
||||
installed = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
});
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 1);
|
||||
});
|
||||
|
||||
const failureCases: Array<{ name: string; overrides: Partial<EnsureOptions>; error: RegExp }> = [
|
||||
{
|
||||
name: 'install result is not ok',
|
||||
overrides: {
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: 'copy failed',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/copy failed/,
|
||||
);
|
||||
});
|
||||
},
|
||||
error: /copy failed/,
|
||||
},
|
||||
{
|
||||
name: 'runtime path remains unresolved after install',
|
||||
overrides: { resolveRuntimePluginPath: () => null },
|
||||
error: /managed runtime plugin assets could not be installed/i,
|
||||
},
|
||||
{
|
||||
name: 'thumbnailer remains missing after install',
|
||||
overrides: { detectInstalledPlugin: () => true, isManagedThumbnailerAvailable: () => false },
|
||||
error: /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||
},
|
||||
];
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/managed runtime plugin assets could not be installed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => true,
|
||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => false,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||
);
|
||||
for (const c of failureCases) {
|
||||
test(`ensureLinuxRuntimePluginAvailable fails when ${c.name}`, async () => {
|
||||
await assert.rejects(() => makeEnsureHarness(c.overrides).run(), c.error);
|
||||
});
|
||||
}
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
|
||||
|
||||
+198
-202
@@ -1,106 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
import {
|
||||
ensureLauncherSetupReady,
|
||||
resolveLauncherGateBackend,
|
||||
waitForSetupCompletion,
|
||||
} from './setup-gate';
|
||||
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
|
||||
|
||||
const commandLineSetupDefaults = {
|
||||
/** An in-progress setup state; tests override only the fields that matter. */
|
||||
function makeState(overrides: Partial<SetupState> = {}): SetupState {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
function completedState(overrides: Partial<SetupState> = {}): SetupState {
|
||||
return makeState({
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'skipped',
|
||||
...commandLineSetupDefaults,
|
||||
},
|
||||
];
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await waitForSetupCompletion({
|
||||
readSetupState: () => sequence.shift() ?? null,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
const cancelledState = () => makeState({ status: 'cancelled' });
|
||||
|
||||
/** Deterministic clock: every `now()` advances 100ms and `sleep` returns immediately. */
|
||||
function fakeClock() {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
return {
|
||||
sleep: async () => undefined,
|
||||
now: () => (value += 100),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reader that serves `states` in order, then repeats the last one; counts its reads. */
|
||||
function sequenceReader(states: Array<SetupState | null>) {
|
||||
const reader = {
|
||||
reads: 0,
|
||||
read: (): SetupState | null => states[Math.min(reader.reads++, states.length - 1)] ?? null,
|
||||
};
|
||||
return reader;
|
||||
}
|
||||
|
||||
for (const c of [
|
||||
{ name: 'completed', last: completedState(), expected: 'completed' },
|
||||
{ name: 'cancelled', last: cancelledState(), expected: 'cancelled' },
|
||||
] as const) {
|
||||
test(`waitForSetupCompletion resolves ${c.name} state after polling through earlier states`, async () => {
|
||||
const reader = sequenceReader([null, makeState(), c.last]);
|
||||
|
||||
const result = await waitForSetupCompletion({
|
||||
readSetupState: reader.read,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(result, 'completed');
|
||||
assert.equal(result, c.expected);
|
||||
assert.equal(reader.reads, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
|
||||
const calls: string[] = [];
|
||||
let reads = 0;
|
||||
const reader = sequenceReader([
|
||||
null,
|
||||
makeState(),
|
||||
completedState({ pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv' }),
|
||||
]);
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
readSetupState: reader.read,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
@@ -116,10 +106,7 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
@@ -129,76 +116,47 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
||||
const calls: string[] = [];
|
||||
let legacyPluginInstalled = true;
|
||||
let reads = 0;
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
|
||||
completionSource: 'user',
|
||||
// The completion timestamp moves forward once the setup app has finished.
|
||||
const reader = sequenceReader([
|
||||
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
|
||||
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
|
||||
completedState({
|
||||
completedAt: '2026-05-12T14:40:00.000Z',
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: reader.read,
|
||||
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
legacyPluginInstalled = false;
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
assert.equal(reads >= 3, true);
|
||||
assert.equal(reader.reads >= 3, true);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
|
||||
const calls: string[] = [];
|
||||
let reads = 0;
|
||||
const reader = sequenceReader([
|
||||
completedState({ lastSeenYomitanDictionaryCount: 2 }),
|
||||
completedState({ lastSeenYomitanDictionaryCount: 2 }),
|
||||
completedState({ completedAt: '2026-05-12T14:30:00.000Z', lastSeenYomitanDictionaryCount: 2 }),
|
||||
]);
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 2,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
readSetupState: reader.read,
|
||||
hasLegacyMpvPlugin: () => true,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
@@ -207,91 +165,129 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 4,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
}),
|
||||
readSetupState: cancelledState,
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
||||
let reads = 0;
|
||||
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
if (reads <= 2) {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
}
|
||||
if (reads === 3) {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
const reader = sequenceReader([
|
||||
cancelledState(),
|
||||
cancelledState(),
|
||||
makeState(),
|
||||
completedState({
|
||||
completionSource: 'legacy_auto_detected',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: reader.read,
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
|
||||
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
|
||||
let launched = 0;
|
||||
let polls = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => state,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
launched += 1;
|
||||
},
|
||||
sleep: async () => {
|
||||
polls += 1;
|
||||
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
|
||||
},
|
||||
now: () => polls,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
assert.equal(launched, 1);
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test('matching Hachidori completion resumes playback without launching setup', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'completed',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('a backend that finished setup earlier passes the gate after switching back', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'yomitan',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'incomplete',
|
||||
completedDictionaryBackends: ['yomitan'],
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gate follows the running app backend until it restarts into the configured one', async () => {
|
||||
const warnings: string[] = [];
|
||||
const state = {
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'yomitan' as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => true,
|
||||
warn: (message) => warnings.push(message),
|
||||
}),
|
||||
'yomitan',
|
||||
);
|
||||
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => false,
|
||||
}),
|
||||
'hachidori',
|
||||
);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => true,
|
||||
readSetupState: () => state,
|
||||
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
+47
-6
@@ -1,7 +1,13 @@
|
||||
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import {
|
||||
getSetupStateDictionaryBackend,
|
||||
isSetupCompleted,
|
||||
type SetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
export async function waitForSetupCompletion(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state)) {
|
||||
if (isSetupCompleted(state, deps.dictionaryBackend)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||
@@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
initialState?: SetupState | null;
|
||||
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
const initialCompleted = isSetupCompleted(deps.initialState);
|
||||
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
|
||||
const initialCompletedAt = deps.initialState?.completedAt ?? null;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
isSetupCompleted(state, deps.dictionaryBackend) &&
|
||||
(!initialCompleted || state?.completedAt !== initialCompletedAt)
|
||||
) {
|
||||
return 'acknowledged';
|
||||
@@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
/**
|
||||
* The app pins its dictionary backend at startup while the config file can change
|
||||
* underneath it. When an app is already running, gate on the backend it recorded
|
||||
* in the setup state rather than the config value it has not restarted into.
|
||||
*/
|
||||
export async function resolveLauncherGateBackend(deps: {
|
||||
configuredBackend: DictionaryBackend;
|
||||
state: SetupState | null;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<DictionaryBackend> {
|
||||
const runningBackend = deps.state
|
||||
? getSetupStateDictionaryBackend(deps.state)
|
||||
: deps.configuredBackend;
|
||||
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
|
||||
return deps.configuredBackend;
|
||||
}
|
||||
deps.warn?.(
|
||||
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
|
||||
);
|
||||
return runningBackend;
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
@@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const initialState = deps.readSetupState();
|
||||
const dictionaryBackend = await resolveLauncherGateBackend({
|
||||
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
|
||||
state: initialState,
|
||||
isAppRunning: deps.isAppRunning,
|
||||
warn: deps.warn,
|
||||
});
|
||||
let setupLaunched = false;
|
||||
const launchSetupApp = () => {
|
||||
if (setupLaunched) return;
|
||||
@@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
launchSetupApp();
|
||||
const result = await waitForLegacyMpvPluginPromptResolution({
|
||||
readSetupState: deps.readSetupState,
|
||||
dictionaryBackend,
|
||||
sleep: deps.sleep,
|
||||
now: deps.now,
|
||||
timeoutMs: deps.timeoutMs,
|
||||
@@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
dictionaryBackend,
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
|
||||
+32
-49
@@ -9,7 +9,6 @@ import {
|
||||
createDefaultSetupState,
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
writeSetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
@@ -407,23 +406,34 @@ setInterval(() => {}, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
test('launcher smoke fixture seeds completed setup state', () => {
|
||||
const smokeCase = createSmokeCase('setup-state');
|
||||
/**
|
||||
* Some sandboxes forbid binding unix sockets (EPERM). The fake mpv needs one, so these tests
|
||||
* cannot pass there; probe once and skip them instead of weakening every assertion.
|
||||
*/
|
||||
function unixSocketsDenied(): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sock-probe-'));
|
||||
try {
|
||||
const configDir = getDefaultConfigDir({
|
||||
xdgConfigHome: smokeCase.xdgConfigHome,
|
||||
homeDir: smokeCase.homeDir,
|
||||
});
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
|
||||
assert.equal(readSetupState(statePath)?.status, 'completed');
|
||||
const probe = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require('node:net').createServer().on('error', (e) => { process.stderr.write(String(e)); process.exit(1); }).listen(${JSON.stringify(path.join(dir, 's.sock'))}, function () { this.close(() => process.exit(0)); });`,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5000 },
|
||||
);
|
||||
return probe.status !== 0 && /eperm|operation not permitted/i.test(probe.stderr);
|
||||
} finally {
|
||||
fs.rmSync(smokeCase.root, { recursive: true, force: true });
|
||||
fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher mpv status returns ready when socket is connectable', async () => {
|
||||
const skipWithoutUnixSockets = unixSocketsDenied() ? 'sandbox denies unix socket binding' : false;
|
||||
|
||||
test(
|
||||
'launcher mpv status returns ready when socket is connectable',
|
||||
{ skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('mpv-status', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
|
||||
@@ -439,20 +449,8 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
env,
|
||||
'mpv-status',
|
||||
);
|
||||
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const fakeMpvError = fakeMpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
|
||||
|
||||
if (unixSocketDenied) {
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /socket not ready/i);
|
||||
} else {
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /socket ready/i);
|
||||
}
|
||||
} finally {
|
||||
if (fakeMpv.exitCode === null) {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -461,11 +459,12 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
@@ -484,13 +483,8 @@ test(
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
||||
|
||||
assert.equal(appStartEntries.length, 1);
|
||||
@@ -523,7 +517,7 @@ test(
|
||||
|
||||
test(
|
||||
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||
const controlServer = await startFakeControlServer(smokeCase);
|
||||
@@ -549,14 +543,8 @@ test(
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const controlEntries = readJsonLines(controlServer.logPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(result.status, 0);
|
||||
if (process.platform === 'linux') {
|
||||
assert.equal(appEntries.length > 0, true);
|
||||
assert.equal(
|
||||
@@ -583,7 +571,7 @@ test(
|
||||
|
||||
test(
|
||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||
fs.writeFileSync(
|
||||
@@ -607,14 +595,9 @@ test(
|
||||
);
|
||||
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
const mpvFirstArgs = mpvEntries[0]?.argv;
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(Array.isArray(mpvFirstArgs), true);
|
||||
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
||||
assert.match(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { parseArgs } from '../config.js';
|
||||
import type { Args } from '../types.js';
|
||||
|
||||
/** Default launcher `Args` (as if run with no CLI input), with `overrides` applied. */
|
||||
export function makeLauncherArgs(overrides: Partial<Args> = {}): Args {
|
||||
return { ...parseArgs([], 'subminer', {}), ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Runs `run` with the given process env values (`undefined` deletes a key) and restores the
|
||||
* originals afterwards, even if `run` throws or rejects.
|
||||
*/
|
||||
export async function withEnv<T>(
|
||||
overrides: Record<string, string | undefined>,
|
||||
run: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const originals = Object.fromEntries(
|
||||
Object.keys(overrides).map((key) => [key, process.env[key]]),
|
||||
);
|
||||
const apply = (values: Record<string, string | undefined>): void => {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
apply(overrides);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
apply(originals);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 655 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user