Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ffa26400
|
||
|
|
06b21a68fa
|
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
name: subminer-release
|
|
||||||
description: Prepare, cut, publish, or repair SubMiner stable and prerelease releases. Use for hands-on release work; do not use for general release questions.
|
|
||||||
---
|
|
||||||
|
|
||||||
# SubMiner release
|
|
||||||
|
|
||||||
Carry out the requested release phase using the repository's current release process.
|
|
||||||
|
|
||||||
## Source of truth
|
|
||||||
|
|
||||||
Read `docs/RELEASING.md` completely before changing files or release state. Treat it as canonical. Read `changes/README.md` when the work touches change fragments or generated release notes.
|
|
||||||
|
|
||||||
Do not copy release commands or policy into this skill. If this skill disagrees with the release guide, follow the guide and reconcile the skill before handoff.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Identify whether the request is for a stable release, prerelease, release preparation, publication, or repair.
|
|
||||||
2. Inspect the current branch, worktree status, package version, pending change fragments, relevant tags, and latest CI state before making changes.
|
|
||||||
3. Follow the matching procedure in `docs/RELEASING.md` in order. Review generated changelog and release-note Markdown before it can be committed or published.
|
|
||||||
4. Run every required gate for the requested release phase. Do not treat a cheaper test lane as a substitute for the documented release gate.
|
|
||||||
5. Before a stable tag, confirm the package and tag versions match and no pending `changes/*.md` fragments remain. Preserve fragments for prereleases as documented.
|
|
||||||
6. Report the resulting version, completed checks, local commit and tag state, remote publication state, skipped platform checks, and any remaining manual work.
|
|
||||||
|
|
||||||
## Authorization boundaries
|
|
||||||
|
|
||||||
- A request to prepare a release stops before commit, tag, push, or remote publication unless the user also authorizes those actions.
|
|
||||||
- A clear request to cut or publish a release includes the documented commit, tag, and push steps. Ask before the first remote mutation when the wording is ambiguous.
|
|
||||||
- Do not edit an existing GitHub release, publish to the AUR, change secrets, or alter signing configuration unless the user explicitly requests that operation.
|
|
||||||
- Do not switch branches without consent.
|
|
||||||
|
|
||||||
## Stop conditions
|
|
||||||
|
|
||||||
Stop and report the blocker when required CI or a release gate fails, authentication is missing, versions disagree, required artifacts are absent, or the worktree contains unexpected changes that overlap the release. Do not tag or publish a partially verified release.
|
|
||||||
@@ -274,8 +274,7 @@ jobs:
|
|||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi \
|
assets/themes/subminer.rasi
|
||||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -273,8 +273,7 @@ jobs:
|
|||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi \
|
assets/themes/subminer.rasi
|
||||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ tests/*
|
|||||||
!.agents/skills/
|
!.agents/skills/
|
||||||
.agents/skills/*
|
.agents/skills/*
|
||||||
!.agents/skills/subminer-change-verification/
|
!.agents/skills/subminer-change-verification/
|
||||||
!.agents/skills/subminer-release/
|
|
||||||
!.agents/skills/subminer-scrum-master/
|
!.agents/skills/subminer-scrum-master/
|
||||||
.agents/skills/subminer-change-verification/*
|
.agents/skills/subminer-change-verification/*
|
||||||
!.agents/skills/subminer-change-verification/SKILL.md
|
!.agents/skills/subminer-change-verification/SKILL.md
|
||||||
@@ -57,8 +56,6 @@ tests/*
|
|||||||
.agents/skills/subminer-change-verification/scripts/*
|
.agents/skills/subminer-change-verification/scripts/*
|
||||||
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||||
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||||
.agents/skills/subminer-release/*
|
|
||||||
!.agents/skills/subminer-release/SKILL.md
|
|
||||||
.agents/skills/subminer-scrum-master/*
|
.agents/skills/subminer-scrum-master/*
|
||||||
!.agents/skills/subminer-scrum-master/SKILL.md
|
!.agents/skills/subminer-scrum-master/SKILL.md
|
||||||
favicon.png
|
favicon.png
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
THUMBNAILER_SOURCE := assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
|
||||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||||
THEME_FILE := subminer.rasi
|
THEME_FILE := subminer.rasi
|
||||||
THUMBNAILER_FILE := subminer-ffmpegthumbnailer.thumbnailer
|
|
||||||
|
|
||||||
# Default install prefix for the wrapper script.
|
# Default install prefix for the wrapper script.
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
@@ -223,13 +221,11 @@ docs-dev: ensure-bun
|
|||||||
|
|
||||||
|
|
||||||
install-linux: build-launcher
|
install-linux: build-launcher
|
||||||
@printf '%s\n' "[INFO] Installing Linux wrapper/support artifacts"
|
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
||||||
@install -d "$(BINDIR)"
|
@install -d "$(BINDIR)"
|
||||||
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
||||||
@install -d "$(LINUX_DATA_DIR)/themes"
|
@install -d "$(LINUX_DATA_DIR)/themes"
|
||||||
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
@install -d "$(LINUX_DATA_DIR)/thumbnailers"
|
|
||||||
@install -m 0644 "./$(THUMBNAILER_SOURCE)" "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
|
||||||
@install -d "$(LINUX_DATA_DIR)/plugin/subminer"
|
@install -d "$(LINUX_DATA_DIR)/plugin/subminer"
|
||||||
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
|
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
|
||||||
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||||
@@ -238,7 +234,7 @@ install-linux: build-launcher
|
|||||||
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||||
printf '%s\n' " Build one with: make build"; \
|
printf '%s\n' " Build one with: make build"; \
|
||||||
fi
|
fi
|
||||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
|
|
||||||
install-macos: build-launcher
|
install-macos: build-launcher
|
||||||
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
|
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
|
||||||
@@ -279,9 +275,8 @@ uninstall:
|
|||||||
uninstall-linux:
|
uninstall-linux:
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
@rm -f "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
|
||||||
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
|
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
|
||||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
|
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
|
||||||
|
|
||||||
uninstall-macos:
|
uninstall-macos:
|
||||||
@rm -f "$(BINDIR)/subminer"
|
@rm -f "$(BINDIR)/subminer"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
|
|||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://www.typescriptlang.org)
|
[](https://www.typescriptlang.org)
|
||||||
|
|
||||||
[](https://github.com/user-attachments/assets/7abab8a9-4e4e-4f06-9f3c-9783e15a3807)
|
[](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 23 MiB |
|
After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.0 MiB |
@@ -1,4 +0,0 @@
|
|||||||
[Thumbnailer Entry]
|
|
||||||
TryExec=ffmpegthumbnailer
|
|
||||||
Exec=ffmpegthumbnailer -i %i -o %o -s %s -f
|
|
||||||
MimeType=video/matroska;video/matroska-3d;video/x-matroska;video/x-matroska-3d;
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "43.4.1",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
"electron": ["electron@42.6.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-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
|
"electron": ["electron@43.4.1", "", { "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-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA=="],
|
||||||
|
|
||||||
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "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-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
|
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "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-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: character dictionary
|
|
||||||
|
|
||||||
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
|
|
||||||
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Prevented embedded subtitle parsing from starving network playback: mounted SMB/NFS media now uses deduplicated mpv live text, while duplicate extraction requests for local media share one ffmpeg process.
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Upgraded the desktop runtime to Electron 43.4.1 and added profile guards that block unsupported runtimes and Electron downgrades before Yomitan storage is loaded.
|
||||||
|
- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
|
||||||
|
- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems (previously the helper required the macOS version of the build machine and crashed on e.g. Ventura, leaving the overlay stuck on "Overlay loading").
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: subtitles
|
area: subtitles
|
||||||
|
|
||||||
- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, navigate song lyrics by sanitized lines instead of generated animation events, and keep sidebar selections on the requested overlapping lyric while preserving unmatched dialogue and signs.
|
- Primary ASS subtitles now use the active parsed cue when it fully accounts for mpv's live text, preventing fill, border, blur, and shadow copies of the same full-span lyric from appearing repeatedly while preserving unmatched overlapping dialogue and signs.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries instead of joining every word together. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
|
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: documentation
|
||||||
|
|
||||||
|
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Secondary subtitle overlays now show every rendered line instead of clipping text after roughly four lines.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
|
|
||||||
@@ -5,7 +5,7 @@ Short recordings of SubMiner's key features and integrations from real playback
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { withBase } from 'vitepress';
|
import { withBase } from 'vitepress';
|
||||||
|
|
||||||
const v = '20260819-1';
|
const v = '20260301-1';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
## Anki Card Mining & Enrichment
|
## Anki Card Mining & Enrichment
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
|
|||||||
|
|
||||||
- [Bun](https://bun.sh)
|
- [Bun](https://bun.sh)
|
||||||
- A system `lua` interpreter for `bun run test:launcher` / `bun run test:plugin:src`
|
- A system `lua` interpreter for `bun run test:launcher` / `bun run test:plugin:src`
|
||||||
- macOS builds compile a Swift helper via `scripts/prepare-build-assets.mjs` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`)
|
- macOS builds compile a Swift helper via `scripts/build-macos-helper.sh` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -64,6 +64,10 @@ make dev-watch # watch TS + renderer and launch E
|
|||||||
make dev-watch-macos # same as dev-watch, forcing --backend macos
|
make dev-watch-macos # same as dev-watch, forcing --backend macos
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Always 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.
|
||||||
|
|
||||||
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
||||||
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
|
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
|
||||||
the mpv plugin at runtime:
|
the mpv plugin at runtime:
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ features:
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { withBase } from 'vitepress';
|
import { withBase } from 'vitepress';
|
||||||
|
|
||||||
const demoAssetVersion = '20260819-1';
|
const demoAssetVersion = '20260223-2';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="landing-shell">
|
<div class="landing-shell">
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ subminer -u
|
|||||||
subminer --update
|
subminer --update
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer`, the rofi theme at `SubMiner/themes/subminer.rasi`, and the scoped Matroska thumbnailer registration under `SubMiner/thumbnailers`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||||
|
|
||||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
|
|||||||
|
|
||||||
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
||||||
|
|
||||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||||
|
|
||||||
## Platform Notes
|
## Platform Notes
|
||||||
|
|
||||||
@@ -456,20 +456,18 @@ sudo chmod +x /usr/local/bin/subminer
|
|||||||
|
|
||||||
### Linux Support Assets
|
### Linux Support Assets
|
||||||
|
|
||||||
SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
mkdir -p ~/.local/share/SubMiner/thumbnailers
|
|
||||||
cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/
|
|
||||||
mkdir -p ~/.local/share/SubMiner/plugin
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs all three assets from the bundled app if one is missing, so manual extraction is mainly useful for pre-seeding or custom setups. Rofi receives the SubMiner data path through its process-local `XDG_DATA_DIRS`, so the thumbnailer registration does not change the desktop-wide configuration.
|
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
|
||||||
|
|
||||||
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||||
|
|
||||||
|
|||||||
@@ -34,22 +34,18 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
|
|||||||
subminer -R /directory # rofi picker, directory shortcut
|
subminer -R /directory # rofi picker, directory shortcut
|
||||||
```
|
```
|
||||||
|
|
||||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme, a scoped `ffmpegthumbnailer` MIME registration, and the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
mkdir -p ~/.local/share/SubMiner/thumbnailers
|
|
||||||
cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/
|
|
||||||
mkdir -p ~/.local/share/SubMiner/plugin
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the `SubMiner` data dir exists, `subminer -u` refreshes these assets automatically. Normal Linux launcher playback checks for all three assets and installs them from the bundled app when one is missing. For `subminer -R`, this repair runs before rofi opens.
|
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
|
||||||
|
|
||||||
When `ffmpegthumbnailer` is installed, SubMiner prepends its own data directory to `XDG_DATA_DIRS` for the rofi process only. This lets rofi recognize the canonical Matroska MIME types used by newer GLib versions without changing the desktop-wide MIME or thumbnailer configuration. An existing registration in your own `$XDG_DATA_HOME/thumbnailers` still takes priority.
|
|
||||||
|
|
||||||
The theme is auto-detected from these paths (first match wins):
|
The theme is auto-detected from these paths (first match wins):
|
||||||
|
|
||||||
|
|||||||
@@ -108,12 +108,8 @@ The secondary bar is a compact top-strip region in the same overlay window. It s
|
|||||||
- Quick comprehension checks without leaving the mining flow.
|
- Quick comprehension checks without leaving the mining flow.
|
||||||
- Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
- Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
||||||
|
|
||||||
For local media, SubMiner can parse supported embedded secondary tracks into timed cues. For remote URLs and files on network mounts, it uses mpv's live secondary subtitle text instead of scanning the media with ffmpeg.
|
|
||||||
|
|
||||||
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
|
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
|
||||||
|
|
||||||
SubMiner collapses duplicate ASS layers in parsed secondary tracks. Exact repeated lines collapse at any length, while distinct simultaneous short lines remain separate. Long dialogue and positioned-sign copies also collapse when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar.
|
|
||||||
|
|
||||||
### Display Modes
|
### Display Modes
|
||||||
|
|
||||||
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 23 MiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.0 MiB |
@@ -9,7 +9,7 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y
|
|||||||
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
|
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
|
||||||
|
|
||||||
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
||||||
- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining.
|
- Clicking any cue seeks mpv to that timestamp.
|
||||||
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
|
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
|
||||||
|
|
||||||
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
|
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ If the overlay position is slightly off, right-click and drag on subtitle text t
|
|||||||
|
|
||||||
If you haven't set up dictionaries yet, see [Yomitan setup](/usage#yomitan-setup) first.
|
If you haven't set up dictionaries yet, see [Yomitan setup](/usage#yomitan-setup) first.
|
||||||
|
|
||||||
|
**"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 version. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record merely to force an older Electron version to open the 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 normalize or 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"**
|
**"Yomitan extension not found in any search path"**
|
||||||
|
|
||||||
SubMiner bundles Yomitan and searches for it in these locations (in order):
|
SubMiner bundles Yomitan and searches for it in these locations (in order):
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
|
|||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--log-level` controls logger verbosity.
|
||||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||||
|
- `--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.
|
||||||
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-19
|
Last verified: 2026-08-18
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -71,24 +71,11 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
|
|
||||||
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
|
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
|
||||||
every live mpv line matches an active parsed cue, it uses the parsed cue text so exact
|
every live mpv line matches an active parsed cue, it uses the parsed cue text so exact
|
||||||
full-span style layers appear once instead of repeating for fill, border, blur, shadow,
|
full-span style layers appear once instead of repeating for fill, border, blur, and
|
||||||
or equivalent whitespace variants. Any unmatched live line keeps the complete live
|
shadow events. Any unmatched live line keeps the complete live stack, preserving
|
||||||
stack, preserving dialogue or signs that overlap a lyric.
|
dialogue or signs that overlap a lyric.
|
||||||
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||||
live work does not contend for Yomitan state.
|
live work does not contend for Yomitan state.
|
||||||
- The initial `time-pos`, explicit renderer seeks, and later seek-like jumps reprocess mpv's
|
|
||||||
current raw `sub-text` after the new playback time is stored. Explicit intent matters because
|
|
||||||
adjacent subtitle jumps can be shorter than the general seek-distance threshold. This corrects
|
|
||||||
ASS cleanup when mpv delivered the destination subtitle before the destination timestamp.
|
|
||||||
- Renderer `sub-seek` commands use the active parsed cue list when available. Simultaneous cues
|
|
||||||
share one boundary, overlapping lyrics advance from the latest active boundary, and mpv's native
|
|
||||||
command remains the fallback when no parsed destination exists. This prevents generated karaoke
|
|
||||||
frames from consuming next/previous subtitle presses.
|
|
||||||
- Subtitle sidebar selections seek past the preceding sanitized cue's overlapping exit span when
|
|
||||||
the selected cue has enough time remaining. This keeps direct row selection on the requested
|
|
||||||
karaoke line while clamping the seek inside that cue.
|
|
||||||
- If startup paints raw text before embedded ASS parsing finishes, parsed cue arrival may replace
|
|
||||||
that provisional line. The one-prime-per-media guard still suppresses identical repeats.
|
|
||||||
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||||
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||||
replace the current cue.
|
replace the current cue.
|
||||||
@@ -97,36 +84,17 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
|
|
||||||
## Secondary Subtitle Flow
|
## Secondary Subtitle Flow
|
||||||
|
|
||||||
- `secondary-sub-text` remains the immediate fallback, so unreadable subtitle sources, remote URLs,
|
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
|
||||||
and files on network mounts still appear without waiting for file resolution. Embedded-track
|
still appear without waiting for file resolution.
|
||||||
extraction is skipped for those sources to avoid competing with playback for network bandwidth.
|
|
||||||
- Parsed secondary text and the live fallback remove exact repeated lines at any length. A
|
|
||||||
flattened-line identity also removes long dialogue/sign repetitions that differ only in
|
|
||||||
whitespace or terminal punctuation, while distinct simultaneous short lines remain separate.
|
|
||||||
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
|
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
|
||||||
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
|
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
|
||||||
source resolver used by primary subtitle prefetching.
|
source resolver used by primary subtitle prefetching.
|
||||||
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
|
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
|
||||||
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
|
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
|
||||||
`secondary-sub-delay`.
|
`secondary-sub-delay`.
|
||||||
- Fragment reconstruction marks positioned parts that span multiple vertical rows as a grid.
|
|
||||||
Secondary text omits those grids instead of flattening a translated table or schedule into one
|
|
||||||
synthetic line. Reconstructed single-line karaoke remains eligible for display.
|
|
||||||
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
||||||
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
||||||
text when a readable source is available.
|
text when a readable source is available.
|
||||||
- Simultaneous parsed cues use whitespace-insensitive identity, so ASS layers that vary only
|
|
||||||
between ordinary, hard, or ideographic spaces appear once.
|
|
||||||
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
|
||||||
authored source order when no usable position exists.
|
|
||||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
|
||||||
survive concatenation, while scripts that discarded their word boundaries remain compact
|
|
||||||
instead of gaining false spaces between syllables. Short runs qualify only when overlapping
|
|
||||||
positioned events also show changing overrides or repeated layer copies; an English or romaji
|
|
||||||
style name alone never turns ordinary dialogue into a lyric.
|
|
||||||
- Recovered canonical ASS text remains active for the generated animation envelope. For
|
|
||||||
reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit
|
|
||||||
fragments from the same style.
|
|
||||||
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
||||||
track-list changes refresh without discarding an unchanged source. Observed
|
track-list changes refresh without discarding an unchanged source. Observed
|
||||||
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Read when: finding internal docs or checking verification status
|
|||||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||||
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
|
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
|
||||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
| 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 |
|
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership |
|
||||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Agent Skills
|
# Agent Skills
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-23
|
Last verified: 2026-08-13
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: using, adding, or changing a repo-local agent workflow skill
|
Read when: using, adding, or changing a repo-local agent workflow skill
|
||||||
|
|
||||||
@@ -12,9 +12,6 @@ Read when: using, adding, or changing a repo-local agent workflow skill
|
|||||||
- `.agents/skills/subminer-change-verification/`
|
- `.agents/skills/subminer-change-verification/`
|
||||||
- Selects the cheapest sufficient repo-native verification lane.
|
- Selects the cheapest sufficient repo-native verification lane.
|
||||||
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
|
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
|
||||||
- `.agents/skills/subminer-release/`
|
|
||||||
- Prepares, cuts, publishes, or repairs stable and prerelease releases.
|
|
||||||
- Defers release procedure and policy to `docs/RELEASING.md`.
|
|
||||||
|
|
||||||
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
|
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
type HistorySeriesEntry,
|
type HistorySeriesEntry,
|
||||||
} from '../history.js';
|
} from '../history.js';
|
||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
||||||
@@ -334,13 +333,6 @@ export async function runHistoryCommand(
|
|||||||
const { args, scriptPath } = context;
|
const { args, scriptPath } = context;
|
||||||
|
|
||||||
checkPickerDependencies(args);
|
checkPickerDependencies(args);
|
||||||
if (args.useRofi) {
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
appPath: context.appPath ?? undefined,
|
|
||||||
scriptPath,
|
|
||||||
logLevel: args.logLevel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||||
|
|
||||||
const dbPath = resolveImmersionDbPath();
|
const dbPath = resolveImmersionDbPath();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { fail } from '../log.js';
|
|||||||
import { runAppCommandWithInherit } from '../mpv.js';
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
import { commandExists } from '../util.js';
|
import { commandExists } from '../util.js';
|
||||||
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
||||||
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
|
||||||
import { shouldForwardLogLevel } from '../types.js';
|
import { shouldForwardLogLevel } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
@@ -65,13 +64,6 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.useRofi && !commandExists('rofi')) {
|
if (args.useRofi && !commandExists('rofi')) {
|
||||||
fail('rofi not found. Install rofi or omit -R for fzf.');
|
fail('rofi not found. Install rofi or omit -R for fzf.');
|
||||||
}
|
}
|
||||||
if (args.useRofi) {
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
appPath,
|
|
||||||
scriptPath,
|
|
||||||
logLevel: args.logLevel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,39 +496,3 @@ test('playback command ensures Linux runtime plugin before mpv launch', async ()
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
assert.deepEqual(calls, ['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');
|
|
||||||
},
|
|
||||||
chooseTarget: async () => {
|
|
||||||
calls.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']);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
chooseTarget,
|
chooseTarget,
|
||||||
checkPickerDependencies,
|
|
||||||
checkDependencies,
|
checkDependencies,
|
||||||
registerCleanup,
|
registerCleanup,
|
||||||
startMpv,
|
startMpv,
|
||||||
@@ -178,7 +177,6 @@ type PlaybackCommandDeps = {
|
|||||||
args: Args,
|
args: Args,
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
||||||
checkPickerDependencies?: (args: Args) => void;
|
|
||||||
checkDependencies: (args: Args) => void;
|
checkDependencies: (args: Args) => void;
|
||||||
registerCleanup: (context: LauncherCommandContext) => void;
|
registerCleanup: (context: LauncherCommandContext) => void;
|
||||||
startMpv: typeof startMpv;
|
startMpv: typeof startMpv;
|
||||||
@@ -203,18 +201,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
await deps.ensurePlaybackSetupReady(context);
|
await deps.ensurePlaybackSetupReady(context);
|
||||||
|
|
||||||
if (!args.target) {
|
if (!args.target) {
|
||||||
(deps.checkPickerDependencies ?? checkPickerDependencies)(args);
|
checkPickerDependencies(args);
|
||||||
}
|
|
||||||
|
|
||||||
let runtimeAssetsReady = false;
|
|
||||||
const ensureRuntimeAssetsReady = async (): Promise<void> => {
|
|
||||||
if (runtimeAssetsReady) return;
|
|
||||||
await deps.ensureRuntimePluginReady(context);
|
|
||||||
runtimeAssetsReady = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!args.target && args.useRofi) {
|
|
||||||
await ensureRuntimeAssetsReady();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
||||||
@@ -279,7 +266,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureRuntimeAssetsReady();
|
await deps.ensureRuntimePluginReady(context);
|
||||||
|
|
||||||
await deps.startMpv(
|
await deps.startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
launcher: { status: 'updated' },
|
launcher: { status: 'updated' },
|
||||||
supportAssets: [
|
supportAssets: [
|
||||||
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
||||||
{
|
|
||||||
status: 'updated',
|
|
||||||
component: 'thumbnailer',
|
|
||||||
message: 'Installed rofi thumbnailer.',
|
|
||||||
},
|
|
||||||
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -57,7 +52,6 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
'info:AppImage update: updated',
|
'info:AppImage update: updated',
|
||||||
'info:Launcher update: updated',
|
'info:Launcher update: updated',
|
||||||
'info:Support assets (theme) update: updated - Installed theme.',
|
'info:Support assets (theme) update: updated - Installed theme.',
|
||||||
'info:Support assets (thumbnailer) update: updated - Installed rofi thumbnailer.',
|
|
||||||
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ import {
|
|||||||
parseSha256Sums,
|
parseSha256Sums,
|
||||||
type FetchLike,
|
type FetchLike,
|
||||||
} from '../../src/main/runtime/update/release-assets.js';
|
} from '../../src/main/runtime/update/release-assets.js';
|
||||||
import {
|
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
|
||||||
updateSupportAssetsFromRelease,
|
|
||||||
type SupportAssetsUpdateResult,
|
|
||||||
} from '../../src/main/runtime/update/support-assets.js';
|
|
||||||
|
|
||||||
type UpdateCommandResponse = {
|
type UpdateCommandResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -39,14 +36,15 @@ type DirectReleaseUpdateRequest = {
|
|||||||
channel: UpdateChannel;
|
channel: UpdateChannel;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DirectSupportAssetsUpdateResult = Omit<SupportAssetsUpdateResult, 'status'> & {
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DirectReleaseUpdateResult = {
|
type DirectReleaseUpdateResult = {
|
||||||
appImage: { status: string; command?: string; message?: string };
|
appImage: { status: string; command?: string; message?: string };
|
||||||
launcher: { status: string; command?: string; message?: string };
|
launcher: { status: string; command?: string; message?: string };
|
||||||
supportAssets: DirectSupportAssetsUpdateResult[];
|
supportAssets: Array<{
|
||||||
|
status: string;
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCommandDeps = {
|
type UpdateCommandDeps = {
|
||||||
@@ -131,7 +129,12 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
|||||||
|
|
||||||
function logUpdateResult(
|
function logUpdateResult(
|
||||||
label: string,
|
label: string,
|
||||||
result: DirectSupportAssetsUpdateResult,
|
result: {
|
||||||
|
status: string;
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -73,21 +73,20 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which
|
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
||||||
// spawns the app with `--ensure-linux-runtime-plugin-assets` when managed
|
// when the runtime plugin/theme are missing — spawns the app with
|
||||||
// support assets are missing and polls up to 30s
|
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
||||||
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
||||||
// never writes that response, so the launcher hangs and the test times out on
|
// never writes that response, so the launcher hangs and the test times out on
|
||||||
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
||||||
// the fake app install the managed support assets and write the response, matching
|
// the fake app install the managed plugin/theme and write the response, matching
|
||||||
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
||||||
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||||
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
||||||
mkdir -p "$data/plugin/subminer" "$data/themes" "$data/thumbnailers"
|
mkdir -p "$data/plugin/subminer" "$data/themes"
|
||||||
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
||||||
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
||||||
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
||||||
printf '[Thumbnailer Entry]\\n' > "$data/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"
|
|
||||||
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
||||||
mkdir -p "$(dirname "$3")"
|
mkdir -p "$(dirname "$3")"
|
||||||
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import {
|
import { findRofiTheme, formatRofiPrompt } from './picker';
|
||||||
findRofiTheme,
|
|
||||||
findRofiThumbnailerDataRoot,
|
|
||||||
formatRofiPrompt,
|
|
||||||
prependXdgDataDir,
|
|
||||||
} from './picker';
|
|
||||||
|
|
||||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
||||||
|
|
||||||
@@ -28,7 +23,6 @@ test('formatRofiPrompt leaves an empty prompt empty', () => {
|
|||||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||||
|
|
||||||
const ROFI_THEME_FILE = 'subminer.rasi';
|
const ROFI_THEME_FILE = 'subminer.rasi';
|
||||||
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
|
|
||||||
|
|
||||||
function makeFile(filePath: string): void {
|
function makeFile(filePath: string): void {
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
@@ -127,42 +121,3 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X
|
|||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findRofiThumbnailerDataRoot resolves the managed XDG data root', () => {
|
|
||||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
|
||||||
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
|
||||||
try {
|
|
||||||
process.env.XDG_DATA_HOME = xdgDataHome;
|
|
||||||
const dataRoot = path.join(xdgDataHome, 'SubMiner');
|
|
||||||
makeFile(path.join(dataRoot, 'thumbnailers', ROFI_THUMBNAILER_FILE));
|
|
||||||
|
|
||||||
const result = withPlatform('linux', () => findRofiThumbnailerDataRoot('/usr/bin/subminer'));
|
|
||||||
assert.equal(result, dataRoot);
|
|
||||||
} finally {
|
|
||||||
if (originalXdgDataHome === undefined) {
|
|
||||||
delete process.env.XDG_DATA_HOME;
|
|
||||||
} else {
|
|
||||||
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
|
||||||
}
|
|
||||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('findRofiThumbnailerDataRoot is Linux-only', () => {
|
|
||||||
assert.equal(
|
|
||||||
withPlatform('darwin', () => findRofiThumbnailerDataRoot('/usr/bin/subminer')),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prependXdgDataDir preserves existing roots and avoids duplicates', () => {
|
|
||||||
const root = '/tmp/subminer-data';
|
|
||||||
assert.equal(
|
|
||||||
prependXdgDataDir(root, `/opt/share${path.delimiter}${root}${path.delimiter}/usr/share`),
|
|
||||||
`${root}${path.delimiter}/opt/share${path.delimiter}/usr/share`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
prependXdgDataDir(root),
|
|
||||||
`${root}${path.delimiter}/usr/local/share${path.delimiter}/usr/share`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -159,9 +159,6 @@ interface RofiIconEntry {
|
|||||||
iconPath?: string;
|
iconPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
|
|
||||||
const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
|
|
||||||
|
|
||||||
function showRofiIconMenu(
|
function showRofiIconMenu(
|
||||||
entries: RofiIconEntry[],
|
entries: RofiIconEntry[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
@@ -392,47 +389,6 @@ export function findRofiTheme(scriptPath: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findRofiThumbnailerDataRoot(scriptPath: string): string | null {
|
|
||||||
if (process.platform !== 'linux') return null;
|
|
||||||
|
|
||||||
const scriptDir = path.dirname(realpathMaybe(scriptPath));
|
|
||||||
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
|
|
||||||
const roots = [
|
|
||||||
path.join(xdgDataHome, 'SubMiner'),
|
|
||||||
path.posix.join('/usr/local/share/SubMiner'),
|
|
||||||
path.posix.join('/usr/share/SubMiner'),
|
|
||||||
path.join(scriptDir, 'assets'),
|
|
||||||
path.join(scriptDir, '..', 'assets'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const root of roots) {
|
|
||||||
if (fs.existsSync(path.join(root, 'thumbnailers', ROFI_THUMBNAILER_FILE))) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prependXdgDataDir(dataRoot: string, currentValue?: string): string {
|
|
||||||
const currentDirs = currentValue
|
|
||||||
? currentValue.split(path.delimiter).filter(Boolean)
|
|
||||||
: DEFAULT_XDG_DATA_DIRS;
|
|
||||||
return [dataRoot, ...currentDirs.filter((candidate) => candidate !== dataRoot)].join(
|
|
||||||
path.delimiter,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRofiThumbnailEnvironment(scriptPath: string): NodeJS.ProcessEnv {
|
|
||||||
if (!commandExists('ffmpegthumbnailer')) return process.env;
|
|
||||||
const dataRoot = findRofiThumbnailerDataRoot(scriptPath);
|
|
||||||
if (!dataRoot) return process.env;
|
|
||||||
return {
|
|
||||||
...process.env,
|
|
||||||
XDG_DATA_DIRS: prependXdgDataDir(dataRoot, process.env.XDG_DATA_DIRS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showRofiMenu(
|
export function showRofiMenu(
|
||||||
videos: string[],
|
videos: string[],
|
||||||
dir: string,
|
dir: string,
|
||||||
@@ -464,7 +420,6 @@ export function showRofiMenu(
|
|||||||
const result = spawnSync('rofi', args, {
|
const result = spawnSync('rofi', args, {
|
||||||
input: buildRofiMenu(videos, dir, recursive),
|
input: buildRofiMenu(videos, dir, recursive),
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
env: buildRofiThumbnailEnvironment(scriptPath),
|
|
||||||
stdio: ['pipe', 'pipe', 'ignore'],
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {
|
import {
|
||||||
ensureLinuxRuntimePluginAvailable,
|
ensureLinuxRuntimePluginAvailable,
|
||||||
installManagedPluginAssetsViaApp,
|
installManagedPluginAssetsViaApp,
|
||||||
@@ -33,7 +31,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
|
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
@@ -54,17 +52,13 @@ test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and th
|
|||||||
calls.push('theme');
|
calls.push('theme');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
isManagedThumbnailerAvailable: () => {
|
|
||||||
calls.push('thumbnailer');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
|
assert.deepEqual(calls, ['detect', 'theme']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
|
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
@@ -86,19 +80,14 @@ test('ensureLinuxRuntimePluginAvailable skips install when all managed assets re
|
|||||||
calls.push('theme');
|
calls.push('theme');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
isManagedThumbnailerAvailable: () => {
|
|
||||||
calls.push('thumbnailer');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
|
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let themeAvailable = false;
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -113,15 +102,10 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
|||||||
},
|
},
|
||||||
isManagedThemeAvailable: () => {
|
isManagedThemeAvailable: () => {
|
||||||
calls.push('theme');
|
calls.push('theme');
|
||||||
return themeAvailable;
|
return false;
|
||||||
},
|
|
||||||
isManagedThumbnailerAvailable: () => {
|
|
||||||
calls.push('thumbnailer');
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
installManagedPluginAssets: async () => {
|
installManagedPluginAssets: async () => {
|
||||||
calls.push('install');
|
calls.push('install');
|
||||||
themeAvailable = true;
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
},
|
},
|
||||||
log: (level, _configured, message) => {
|
log: (level, _configured, message) => {
|
||||||
@@ -133,68 +117,13 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
|||||||
'detect',
|
'detect',
|
||||||
'resolve',
|
'resolve',
|
||||||
'theme',
|
'theme',
|
||||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
'install',
|
'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',
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
'resolve',
|
'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 () => {
|
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let resolveCount = 0;
|
let resolveCount = 0;
|
||||||
@@ -208,8 +137,6 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
|
|||||||
calls.push(`resolve:${resolveCount}`);
|
calls.push(`resolve:${resolveCount}`);
|
||||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||||
},
|
},
|
||||||
isManagedThemeAvailable: () => true,
|
|
||||||
isManagedThumbnailerAvailable: () => true,
|
|
||||||
installManagedPluginAssets: async () => {
|
installManagedPluginAssets: async () => {
|
||||||
calls.push('install');
|
calls.push('install');
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
@@ -221,9 +148,9 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'resolve:1',
|
'resolve:1',
|
||||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
'install',
|
'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',
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
'resolve:2',
|
'resolve:2',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -264,60 +191,6 @@ test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolv
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
|
|
||||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
|
|
||||||
const thumbnailerPath = path.join(
|
|
||||||
xdgDataHome,
|
|
||||||
'SubMiner',
|
|
||||||
'thumbnailers',
|
|
||||||
'subminer-ffmpegthumbnailer.thumbnailer',
|
|
||||||
);
|
|
||||||
fs.mkdirSync(thumbnailerPath, { recursive: true });
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
() =>
|
|
||||||
ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
|
||||||
xdgDataHome,
|
|
||||||
detectInstalledPlugin: () => true,
|
|
||||||
isManagedThemeAvailable: () => true,
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
log: () => {},
|
|
||||||
}),
|
|
||||||
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
|
||||||
);
|
|
||||||
assert.deepEqual(calls, ['install']);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
||||||
let waited = false;
|
let waited = false;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type EnsureLinuxRuntimePluginAvailableOptions = {
|
|||||||
detectInstalledPlugin?: () => boolean;
|
detectInstalledPlugin?: () => boolean;
|
||||||
resolveRuntimePluginPath?: () => string | null;
|
resolveRuntimePluginPath?: () => string | null;
|
||||||
isManagedThemeAvailable?: () => boolean;
|
isManagedThemeAvailable?: () => boolean;
|
||||||
isManagedThumbnailerAvailable?: () => boolean;
|
|
||||||
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||||
log?: PreflightLog;
|
log?: PreflightLog;
|
||||||
};
|
};
|
||||||
@@ -49,14 +48,6 @@ function resolveConfiguredLogLevel(
|
|||||||
return logLevel ?? 'warn';
|
return logLevel ?? 'warn';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRegularFile(filePath: string): boolean {
|
|
||||||
try {
|
|
||||||
return fs.statSync(filePath).isFile();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForInstallResponse(
|
async function waitForInstallResponse(
|
||||||
responsePath: string,
|
responsePath: string,
|
||||||
): Promise<RuntimePluginPreflightResponse | null> {
|
): Promise<RuntimePluginPreflightResponse | null> {
|
||||||
@@ -179,17 +170,15 @@ export async function ensureLinuxRuntimePluginAvailable(
|
|||||||
});
|
});
|
||||||
const isManagedThemeAvailable =
|
const isManagedThemeAvailable =
|
||||||
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
||||||
const isManagedThumbnailerAvailable =
|
|
||||||
options.isManagedThumbnailerAvailable ?? (() => isRegularFile(managedPaths.thumbnailerPath));
|
|
||||||
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||||
if (runtimePluginAvailable && isManagedThemeAvailable() && isManagedThumbnailerAvailable()) {
|
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'info',
|
'info',
|
||||||
configuredLogLevel,
|
configuredLogLevel,
|
||||||
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
);
|
);
|
||||||
const installManagedPluginAssets =
|
const installManagedPluginAssets =
|
||||||
options.installManagedPluginAssets ??
|
options.installManagedPluginAssets ??
|
||||||
@@ -218,21 +207,16 @@ export async function ensureLinuxRuntimePluginAvailable(
|
|||||||
log(
|
log(
|
||||||
'info',
|
'info',
|
||||||
configuredLogLevel,
|
configuredLogLevel,
|
||||||
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}`,
|
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
|
||||||
);
|
);
|
||||||
const runtimePluginAvailableAfterInstall =
|
const runtimePluginPath = resolveRuntimePluginPath();
|
||||||
installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
if (runtimePluginPath) {
|
||||||
if (
|
|
||||||
runtimePluginAvailableAfterInstall &&
|
|
||||||
isManagedThemeAvailable() &&
|
|
||||||
isManagedThumbnailerAvailable()
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
`Linux managed runtime plugin assets could not be installed. ` +
|
`Linux managed runtime plugin assets could not be installed. ` +
|
||||||
`Checked paths: plugin=${managedPaths.pluginEntrypointPath} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}. ` +
|
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
|
||||||
'Launch aborted before starting mpv.';
|
'Launch aborted before starting mpv.';
|
||||||
log('warn', configuredLogLevel, message);
|
log('warn', configuredLogLevel, message);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
|
|||||||
@@ -165,14 +165,11 @@ if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
|
|||||||
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
||||||
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
||||||
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
||||||
const thumbnailerPath = path.join(dataDir, 'thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer');
|
|
||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
||||||
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
||||||
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
||||||
fs.writeFileSync(thumbnailerPath, '[Thumbnailer Entry]\\n');
|
|
||||||
if (responsePath) {
|
if (responsePath) {
|
||||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||||
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
||||||
@@ -623,22 +620,11 @@ test(
|
|||||||
);
|
);
|
||||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
assert.match(result.stdout, /managed plugin\/theme\/thumbnailer assets/i);
|
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
|
||||||
fs.existsSync(
|
|
||||||
path.join(
|
|
||||||
smokeCase.xdgDataHome,
|
|
||||||
'SubMiner',
|
|
||||||
'thumbnailers',
|
|
||||||
'subminer-ffmpegthumbnailer.thumbnailer',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.4-beta.4",
|
"version": "0.19.4-beta.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "43.4.1",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"undici": "7.29.0",
|
"undici": "7.29.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ package() {
|
|||||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
||||||
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
||||||
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
||||||
install -Dm644 "${srcdir}/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" \
|
|
||||||
"${pkgdir}/usr/share/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"
|
|
||||||
|
|
||||||
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
||||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
||||||
|
|||||||
@@ -4,46 +4,37 @@
|
|||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
|
- **Library Merge and Move**
|
||||||
- Library Merge & Reassignment
|
- Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card.
|
||||||
- Duplicate library cards for the same show can be combined: select entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card.
|
- Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair.
|
||||||
- Episodes can be moved to a different entry with a per-episode "→" button, fixing stray files that split off their own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair.
|
- Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation.
|
||||||
- Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
|
|
||||||
|
|
||||||
- Duplicate Line Cleanup
|
|
||||||
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once confirmed.
|
|
||||||
- A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
|
|
||||||
- Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Anki Audio Generation on Network Drives**
|
||||||
|
- Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams.
|
||||||
|
- Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one.
|
||||||
|
- **Duplicate Subtitle Line Stats**
|
||||||
|
- Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected.
|
||||||
|
- Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched.
|
||||||
|
- **Overlay Modals on macOS and Windows**
|
||||||
|
- Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space.
|
||||||
|
- Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions.
|
||||||
|
- **Wayland File Drag-and-Drop**
|
||||||
|
- Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv.
|
||||||
|
- **Windows Mouse Lag**
|
||||||
|
- Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking.
|
||||||
|
- **Mining Clip Accuracy**
|
||||||
|
- Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match.
|
||||||
|
- **Linux Notifications**
|
||||||
|
- Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change.
|
||||||
|
- **Stats Delete Performance**
|
||||||
|
- Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes.
|
||||||
|
- Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history.
|
||||||
|
- Session deletes on large libraries dropped from minutes to milliseconds.
|
||||||
|
|
||||||
- Subtitle Duplication from Karaoke & Animated Signs
|
### Docs
|
||||||
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs.
|
- **Feature Demos Page**
|
||||||
- Fragmented karaoke now preserves the spaces the author placed between words instead of joining them together, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame.
|
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
|
||||||
- The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected.
|
|
||||||
|
|
||||||
- Anki Media Generation
|
|
||||||
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`.
|
|
||||||
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished.
|
|
||||||
|
|
||||||
- Character Dictionary Performance & Notifications
|
|
||||||
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time.
|
|
||||||
- Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
|
|
||||||
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
|
|
||||||
|
|
||||||
- Overlay Reliability
|
|
||||||
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
|
|
||||||
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems like Ventura instead of crashing and getting stuck on "Overlay loading."
|
|
||||||
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
|
|
||||||
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
|
|
||||||
|
|
||||||
- Stats Dashboard
|
|
||||||
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds.
|
|
||||||
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups.
|
|
||||||
- Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
|
|
||||||
|
|
||||||
- Linux Launcher Thumbnails
|
|
||||||
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
@@ -56,13 +47,6 @@
|
|||||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||||
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
|
|
||||||
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
|
|
||||||
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
|
|
||||||
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
|
|
||||||
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
|
|
||||||
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
|
|
||||||
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build macOS window tracking helper binary
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift"
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts"
|
||||||
|
OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos"
|
||||||
|
OUTPUT_SOURCE_COPY="$OUTPUT_DIR/get-mpv-window-macos.swift"
|
||||||
|
|
||||||
|
fallback_to_source() {
|
||||||
|
echo "Falling back to source fallback: $OUTPUT_SOURCE_COPY"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
cp "$SWIFT_SOURCE" "$OUTPUT_SOURCE_COPY"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_swift_helper() {
|
||||||
|
echo "Compiling macOS window tracking helper..."
|
||||||
|
if ! command -v swiftc >/dev/null 2>&1; then
|
||||||
|
echo "swiftc not found in PATH; skipping compilation."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$OUTPUT_BINARY"
|
||||||
|
echo "✓ Built $OUTPUT_BINARY"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional skip flag for non-macOS CI/dev environments
|
||||||
|
if [[ "${SUBMINER_SKIP_MACOS_HELPER_BUILD:-}" == "1" ]]; then
|
||||||
|
echo "Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)"
|
||||||
|
fallback_to_source
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only build on macOS
|
||||||
|
if [[ "$(uname)" != "Darwin" ]]; then
|
||||||
|
echo "Skipping macOS helper build (not on macOS)"
|
||||||
|
fallback_to_source
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# Compile Swift script to binary, fallback to source if unavailable or compilation fails
|
||||||
|
if ! build_swift_helper; then
|
||||||
|
fallback_to_source
|
||||||
|
fi
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
FILE="${1:-}"
|
|
||||||
|
|
||||||
if [[ ! -f "$FILE" ]]; then
|
|
||||||
printf 'Not a file: %s\n' "${FILE:-<missing>}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! mpv --no-config --no-terminal --msg-level=all=no --vo=null --ao=null --frames=1 -- "$FILE"; then
|
|
||||||
printf 'Not playable by mpv: %s\n' "$FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec subminer app --dev --launch-mpv "$FILE"
|
|
||||||
@@ -19,8 +19,7 @@ Options:
|
|||||||
-w, --webp Generate animated WebP preview
|
-w, --webp Generate animated WebP preview
|
||||||
|
|
||||||
Encoding profile:
|
Encoding profile:
|
||||||
- Crop: mpv region at 1920x1080, x=760 y=205 on a 3440x1440 canvas
|
- Crop: 1920x1080 at x=760 y=200
|
||||||
- Output size: 1920x1080
|
|
||||||
- MP4: H.264 + AAC
|
- MP4: H.264 + AAC
|
||||||
- WebM: AV1/VP9 + Opus at 30 fps
|
- WebM: AV1/VP9 + Opus at 30 fps
|
||||||
USAGE
|
USAGE
|
||||||
@@ -149,8 +148,7 @@ pick_webp_encoder() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# OBS may resize the 3440x1440 canvas, so scale the mpv bounds with the input.
|
crop_vf="crop=1920:1080:760:205"
|
||||||
crop_vf="crop=1920*iw/3440:1080*ih/1440:760*iw/3440:205*ih/1440,scale=1920:1080:flags=lanczos"
|
|
||||||
webm_vf="${crop_vf},fps=30"
|
webm_vf="${crop_vf},fps=30"
|
||||||
|
|
||||||
echo "Generating MP4: $mp4_out"
|
echo "Generating MP4: $mp4_out"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function toBashPath(filePath: string): string {
|
|||||||
return `${drive.toUpperCase()}:/${rest}`;
|
return `${drive.toUpperCase()}:/${rest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('mkv-to-readme-video builds every output with the scaled mpv crop', () => {
|
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
const inputPath = path.join(root, 'sample.mkv');
|
const inputPath = path.join(root, 'sample.mkv');
|
||||||
@@ -104,9 +104,5 @@ touch "$output"
|
|||||||
|
|
||||||
const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8');
|
const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8');
|
||||||
assert.match(ffmpegLog, /-c:v libwebp_anim/);
|
assert.match(ffmpegLog, /-c:v libwebp_anim/);
|
||||||
const scaledCropUses = ffmpegLog.match(
|
|
||||||
/-vf crop=1920\*iw\/3440:1080\*ih\/1440:760\*iw\/3440:205\*ih\/1440,scale=1920:1080:flags=lanczos/g,
|
|
||||||
);
|
|
||||||
assert.equal(scaledCropUses?.length, 4);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -53,16 +52,6 @@ function fallbackToMacosSource() {
|
|||||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an
|
|
||||||
// explicit target, swiftc stamps the build machine's OS version as the binary's
|
|
||||||
// minimum and the helper fails to load on older systems (#213). The arch stays
|
|
||||||
// the host's, matching the single-arch app electron-builder packages here.
|
|
||||||
const MACOS_HELPER_DEPLOYMENT_TARGET = '12.0';
|
|
||||||
|
|
||||||
function macosHelperTarget() {
|
|
||||||
return `${os.arch() === 'x64' ? 'x86_64' : 'arm64'}-apple-macos${MACOS_HELPER_DEPLOYMENT_TARGET}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSkipMacosHelperBuild() {
|
function shouldSkipMacosHelperBuild() {
|
||||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||||
}
|
}
|
||||||
@@ -83,13 +72,9 @@ function buildMacosHelper() {
|
|||||||
ensureDir(scriptsOutputDir);
|
ensureDir(scriptsOutputDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(
|
execFileSync('swiftc', ['-O', macosHelperSourcePath, '-o', macosHelperBinaryPath], {
|
||||||
'swiftc',
|
stdio: 'inherit',
|
||||||
['-O', '-target', macosHelperTarget(), macosHelperSourcePath, '-o', macosHelperBinaryPath],
|
});
|
||||||
{
|
|
||||||
stdio: 'inherit',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
||||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
|||||||
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
|
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
|
||||||
assert.notEqual(buildFunctionIndex, -1);
|
assert.notEqual(buildFunctionIndex, -1);
|
||||||
|
|
||||||
const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex);
|
const swiftcIndex = source.indexOf("execFileSync('swiftc'", buildFunctionIndex);
|
||||||
assert.notEqual(swiftcIndex, -1);
|
assert.notEqual(swiftcIndex, -1);
|
||||||
|
|
||||||
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
|
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
|
||||||
@@ -18,10 +18,3 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
|||||||
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
|
||||||
// version as the helper's minimum, so released builds refuse to load on older macOS.
|
|
||||||
test('macOS helper is compiled with an explicit deployment target', () => {
|
|
||||||
assert.match(source, /-target/);
|
|
||||||
assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
|||||||
pkgbuild,
|
pkgbuild,
|
||||||
/^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m,
|
/^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m,
|
||||||
);
|
);
|
||||||
assert.match(pkgbuild, /assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/);
|
|
||||||
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
||||||
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
|
async function backfillMissingCoverBlob(): Promise<void> {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -103,9 +103,15 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
|
|||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
test(
|
||||||
|
'fetchIfMissing backfills a missing blob from an existing cover URL',
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
backfillMissingCoverBlob,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -179,7 +185,13 @@ test('fetchIfMissing reuses cached cover art from another video in the same anim
|
|||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fetchIfMissing reuses cached cover art from another video in the same anime',
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
reuseCachedAnimeCoverArt,
|
||||||
|
);
|
||||||
|
|
||||||
function createJsonResponse(payload: unknown): Response {
|
function createJsonResponse(payload: unknown): Response {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ export {
|
|||||||
} from './tokenizer/yomitan-parser-runtime';
|
} from './tokenizer/yomitan-parser-runtime';
|
||||||
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
||||||
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||||
export {
|
|
||||||
resolveSanitizedSubtitleSeekCommand,
|
|
||||||
subtitleCueListSeekTime,
|
|
||||||
subtitleCueSeekTime,
|
|
||||||
} from './subtitle-cue-navigation';
|
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ test('non-macOS modal overlay remains a regular window', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(options.type, undefined);
|
assert.equal(options.type, undefined);
|
||||||
|
assert.equal(options.roundedCorners, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function buildOverlayWindowOptions(
|
|||||||
paintWhenInitiallyHidden: true,
|
paintWhenInitiallyHidden: true,
|
||||||
backgroundColor: '#00000000',
|
backgroundColor: '#00000000',
|
||||||
frame: false,
|
frame: false,
|
||||||
|
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
resizable: shouldAllowCompositorResize,
|
resizable: shouldAllowCompositorResize,
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
const MIN_FLATTENED_DUPLICATE_LENGTH = 16;
|
|
||||||
const TERMINAL_SENTENCE_PUNCTUATION = /[.!?。!?…⋯]+$/gu;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identifies long lines that become duplicates when positioned ASS events are
|
|
||||||
* flattened into the secondary subtitle bar. Short dialogue stays distinct.
|
|
||||||
*/
|
|
||||||
export function flattenedSecondarySubtitleLineIdentity(text: string): string | null {
|
|
||||||
const identity = text
|
|
||||||
.normalize('NFKC')
|
|
||||||
.replace(/\s+/gu, '')
|
|
||||||
.replace(TERMINAL_SENTENCE_PUNCTUATION, '');
|
|
||||||
|
|
||||||
return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null;
|
|
||||||
}
|
|
||||||
@@ -67,6 +67,7 @@ export function buildStatsWindowOptions(options: {
|
|||||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||||
frame: false,
|
frame: false,
|
||||||
|
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||||
transparent: false,
|
transparent: false,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(options.type, undefined);
|
assert.equal(options.type, undefined);
|
||||||
|
assert.equal(options.roundedCorners, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats panels present after document load on macOS', () => {
|
test('stats panels present after document load on macOS', () => {
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRu
|
|||||||
const isFlush =
|
const isFlush =
|
||||||
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
||||||
if (
|
if (
|
||||||
first.source !== undefined ||
|
first.source === 'canonical-ass' ||
|
||||||
next.source !== undefined ||
|
next.source === 'canonical-ass' ||
|
||||||
next.text !== first.text ||
|
next.text !== first.text ||
|
||||||
assStyleKey(next) !== styleKey ||
|
assStyleKey(next) !== styleKey ||
|
||||||
!isFlush
|
!isFlush
|
||||||
@@ -223,7 +223,7 @@ function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number)
|
|||||||
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
||||||
* changes from event to event, which is how per-frame typesetting is authored.
|
* changes from event to event, which is how per-frame typesetting is authored.
|
||||||
*/
|
*/
|
||||||
export function hasAssAnimationEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
|
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
|
||||||
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
resolveSanitizedSubtitleSeekCommand,
|
|
||||||
subtitleCueListSeekTime,
|
|
||||||
subtitleCueSeekTime,
|
|
||||||
} from './subtitle-cue-navigation';
|
|
||||||
|
|
||||||
test('next subtitle navigation skips generated ASS events and seeks to the next sanitized cue', () => {
|
|
||||||
const cues = [
|
|
||||||
{
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 13,
|
|
||||||
text: 'first lyric',
|
|
||||||
source: 'canonical-ass' as const,
|
|
||||||
animationStartTime: 9.7,
|
|
||||||
animationEndTime: 13.4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: 13,
|
|
||||||
endTime: 16,
|
|
||||||
text: 'second lyric',
|
|
||||||
source: 'canonical-ass' as const,
|
|
||||||
animationStartTime: 12.7,
|
|
||||||
animationEndTime: 16.4,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
|
|
||||||
'seek',
|
|
||||||
13.08,
|
|
||||||
'absolute+exact',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('next subtitle navigation treats simultaneous sanitized cues as one line boundary', () => {
|
|
||||||
const cues = [
|
|
||||||
{ startTime: 10, endTime: 13, text: 'romaji' },
|
|
||||||
{ startTime: 10.02, endTime: 13, text: 'English' },
|
|
||||||
{ startTime: 13, endTime: 16, text: 'next romaji' },
|
|
||||||
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
|
||||||
'seek',
|
|
||||||
13.08,
|
|
||||||
'absolute+exact',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('next subtitle navigation advances past the latest overlapping lyric', () => {
|
|
||||||
const cues = [
|
|
||||||
{ startTime: 10, endTime: 14, text: 'exiting lyric' },
|
|
||||||
{ startTime: 13, endTime: 16, text: 'current lyric' },
|
|
||||||
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
|
||||||
'seek',
|
|
||||||
16.08,
|
|
||||||
'absolute+exact',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('previous subtitle navigation leaves the current cue and seeks to the prior cue', () => {
|
|
||||||
const cues = [
|
|
||||||
{ startTime: 10, endTime: 12, text: 'first line' },
|
|
||||||
{ startTime: 13, endTime: 16, text: 'current line' },
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
|
||||||
'seek',
|
|
||||||
10.08,
|
|
||||||
'absolute+exact',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
|
||||||
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
|
||||||
|
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
|
||||||
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08);
|
|
||||||
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar cue selection clears an overlapping previous lyric', () => {
|
|
||||||
const cues = [
|
|
||||||
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
|
|
||||||
{ startTime: 3, endTime: 5, text: 'selected lyric' },
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => {
|
|
||||||
const cues = [
|
|
||||||
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
|
|
||||||
{ startTime: 3, endTime: 3.2, text: 'selected lyric' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const seekTime = subtitleCueListSeekTime(cues, cues[1]!);
|
|
||||||
assert.ok(seekTime >= 3.19);
|
|
||||||
assert.ok(seekTime < cues[1]!.endTime);
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
|
||||||
|
|
||||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
|
||||||
const CUE_BOUNDARY_SEEK_OFFSET_SECONDS = 0.08;
|
|
||||||
const CUE_END_GUARD_SECONDS = 0.01;
|
|
||||||
|
|
||||||
type CueGroup = {
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
cue: SubtitleCue;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isValidCue(cue: SubtitleCue): boolean {
|
|
||||||
return (
|
|
||||||
Number.isFinite(cue.startTime) && Number.isFinite(cue.endTime) && cue.endTime > cue.startTime
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupCueBoundaries(cues: readonly SubtitleCue[]): CueGroup[] {
|
|
||||||
const sorted = cues.filter(isValidCue).sort((left, right) => {
|
|
||||||
return left.startTime - right.startTime || left.endTime - right.endTime;
|
|
||||||
});
|
|
||||||
const groups: CueGroup[] = [];
|
|
||||||
|
|
||||||
for (const cue of sorted) {
|
|
||||||
const current = groups.at(-1);
|
|
||||||
if (current && cue.startTime - current.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS) {
|
|
||||||
current.endTime = Math.max(current.endTime, cue.endTime);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
groups.push({ startTime: cue.startTime, endTime: cue.endTime, cue });
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A small offset avoids asking mpv to render exactly on a subtitle boundary. */
|
|
||||||
export function subtitleCueSeekTime(cue: SubtitleCue): number {
|
|
||||||
return Math.max(
|
|
||||||
cue.startTime,
|
|
||||||
Math.min(cue.endTime - CUE_END_GUARD_SECONDS, cue.startTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose a stable point inside a selected cue. Karaoke lines can overlap while the
|
|
||||||
* previous line animates out, so a sidebar selection should clear that overlap when
|
|
||||||
* the selected cue has enough time remaining.
|
|
||||||
*/
|
|
||||||
export function subtitleCueListSeekTime(
|
|
||||||
cues: readonly SubtitleCue[],
|
|
||||||
selectedCue: SubtitleCue,
|
|
||||||
): number {
|
|
||||||
const groups = groupCueBoundaries(cues);
|
|
||||||
const selectedGroupIndex = groups.findIndex(
|
|
||||||
(group) =>
|
|
||||||
selectedCue.startTime >= group.startTime &&
|
|
||||||
selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS,
|
|
||||||
);
|
|
||||||
const previousGroupEndTime =
|
|
||||||
selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined;
|
|
||||||
if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) {
|
|
||||||
return subtitleCueSeekTime(selectedCue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(
|
|
||||||
selectedCue.startTime,
|
|
||||||
Math.min(
|
|
||||||
selectedCue.endTime - CUE_END_GUARD_SECONDS,
|
|
||||||
previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can
|
|
||||||
* contain hundreds of subtitle events for one visible line, while the parsed list has
|
|
||||||
* already collapsed those events into the authored lines the user expects to navigate.
|
|
||||||
*/
|
|
||||||
export function resolveSanitizedSubtitleSeekCommand(
|
|
||||||
command: readonly (string | number)[],
|
|
||||||
cues: readonly SubtitleCue[],
|
|
||||||
currentTimeSec: number,
|
|
||||||
): (string | number)[] | null {
|
|
||||||
if (
|
|
||||||
command.length < 2 ||
|
|
||||||
command[0] !== 'sub-seek' ||
|
|
||||||
(command[1] !== -1 && command[1] !== 1) ||
|
|
||||||
!Number.isFinite(currentTimeSec)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = groupCueBoundaries(cues);
|
|
||||||
if (groups.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeIndex = -1;
|
|
||||||
for (const [index, group] of groups.entries()) {
|
|
||||||
if (group.startTime <= currentTimeSec && group.endTime > currentTimeSec) {
|
|
||||||
activeIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let destination: CueGroup | undefined;
|
|
||||||
if (command[1] === 1) {
|
|
||||||
destination =
|
|
||||||
activeIndex >= 0
|
|
||||||
? groups[activeIndex + 1]
|
|
||||||
: groups.find((group) => group.startTime > currentTimeSec);
|
|
||||||
} else if (activeIndex >= 0) {
|
|
||||||
destination = groups[activeIndex - 1];
|
|
||||||
} else {
|
|
||||||
for (let index = groups.length - 1; index >= 0; index -= 1) {
|
|
||||||
const group = groups[index]!;
|
|
||||||
if (group.startTime < currentTimeSec) {
|
|
||||||
destination = group;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!destination) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
|
||||||
}
|
|
||||||
@@ -570,63 +570,6 @@ test('parseSubtitleCues does not promote a short animated fragment as a complete
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues keeps short animated English dialogue as separate cues', () => {
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Dialogue: 0,0:00:01.00,0:00:02.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}Hi',
|
|
||||||
'Dialogue: 0,0:00:02.00,0:00:03.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}No',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
|
||||||
{ startTime: 1, endTime: 2, text: 'Hi' },
|
|
||||||
{ startTime: 2, endTime: 3, text: 'No' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues does not reconstruct an already canonical English cue', () => {
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF',
|
|
||||||
'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF',
|
|
||||||
'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF',
|
|
||||||
'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
|
||||||
{
|
|
||||||
startTime: 1,
|
|
||||||
endTime: 3,
|
|
||||||
text: 'POOF',
|
|
||||||
source: 'canonical-ass',
|
|
||||||
animationStartTime: 1,
|
|
||||||
animationEndTime: 3,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
|
|
||||||
'Dialogue: 1,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
|
||||||
{
|
|
||||||
startTime: 1,
|
|
||||||
endTime: 3,
|
|
||||||
text: 'Oh',
|
|
||||||
source: 'reconstructed-ass',
|
|
||||||
animationStartTime: 1,
|
|
||||||
animationEndTime: 3,
|
|
||||||
assStyle: 'Karaoke',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
|
|||||||
@@ -8,27 +8,19 @@ import {
|
|||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||||
|
|
||||||
export type AssCueLayout =
|
|
||||||
| { kind: 'positioned'; sourceOrder: number; y: number }
|
|
||||||
| { kind: 'fragment-grid'; sourceOrder: number }
|
|
||||||
| { kind: 'source-order'; sourceOrder: number };
|
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
text: string;
|
text: string;
|
||||||
/** How a complete line was recovered from generated ASS animation events. */
|
/** A complete authored line recovered from matching generated ASS animation events. */
|
||||||
source?: 'canonical-ass' | 'reconstructed-ass';
|
source?: 'canonical-ass';
|
||||||
/**
|
/**
|
||||||
* Full span of the generated animation events a recovered cue replaced. Entrance and
|
* Full span of the generated animation events a canonical cue replaced. Entrance and
|
||||||
* exit frames can run past canonical authored timing.
|
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
|
||||||
|
* matching must use this envelope while display and history keep the authored timing.
|
||||||
*/
|
*/
|
||||||
animationStartTime?: number;
|
animationStartTime?: number;
|
||||||
animationEndTime?: number;
|
animationEndTime?: number;
|
||||||
/** ASS style retained only for fragment-reconstructed lines. */
|
|
||||||
assStyle?: string;
|
|
||||||
/** Authored ASS ordering metadata used when flattening simultaneous positioned cues. */
|
|
||||||
assLayout?: AssCueLayout;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +29,7 @@ export interface SubtitleCue {
|
|||||||
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
||||||
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
||||||
* outside the parser, so the public API exposes only timing, text, and the optional
|
* outside the parser, so the public API exposes only timing, text, and the optional
|
||||||
* recovery marker used by live subtitle consumers.
|
* canonical-source marker used by live subtitle consumers.
|
||||||
*/
|
*/
|
||||||
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||||
/** Text exactly as authored, override blocks and all. */
|
/** Text exactly as authored, override blocks and all. */
|
||||||
@@ -83,55 +75,15 @@ function parseTimestamp(
|
|||||||
* line breaks, matching what mpv hands over for the same line played live. No layer
|
* line breaks, matching what mpv hands over for the same line played live. No layer
|
||||||
* downstream decodes ASS again.
|
* downstream decodes ASS again.
|
||||||
*/
|
*/
|
||||||
function decodeSubtitleCueText(text: string): string {
|
|
||||||
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeSubtitleCueText(text: string): string {
|
function sanitizeSubtitleCueText(text: string): string {
|
||||||
return decodeSubtitleCueText(text).trim();
|
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
||||||
}
|
|
||||||
|
|
||||||
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
|
||||||
if (assLayout) {
|
|
||||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
|
||||||
}
|
|
||||||
return cue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||||
return cues.map(
|
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
|
||||||
({
|
source
|
||||||
startTime,
|
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
|
||||||
endTime,
|
: { startTime, endTime, text },
|
||||||
text,
|
|
||||||
source,
|
|
||||||
animationStartTime,
|
|
||||||
animationEndTime,
|
|
||||||
style,
|
|
||||||
assLayout,
|
|
||||||
}) => {
|
|
||||||
const common = {
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
if (source === 'reconstructed-ass') {
|
|
||||||
return attachAssLayout(
|
|
||||||
{
|
|
||||||
...common,
|
|
||||||
source,
|
|
||||||
animationStartTime,
|
|
||||||
animationEndTime,
|
|
||||||
assStyle: style,
|
|
||||||
},
|
|
||||||
assLayout,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return attachAssLayout(
|
|
||||||
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
|
||||||
assLayout,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +159,6 @@ const MIN_CANONICAL_ANIMATION_EVENTS = 3;
|
|||||||
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
||||||
// not enough evidence that the fragment represents an authored line boundary.
|
// not enough evidence that the fragment represents an authored line boundary.
|
||||||
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
||||||
const MIN_FRAGMENT_LINE_EVENTS = 8;
|
|
||||||
const MIN_FRAGMENT_LINE_PARTS = 4;
|
|
||||||
const MAX_FRAGMENT_MEDIAN_LENGTH = 4;
|
|
||||||
const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2;
|
|
||||||
const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48;
|
|
||||||
|
|
||||||
function parseAssTimestamp(raw: string): number | null {
|
function parseAssTimestamp(raw: string): number | null {
|
||||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||||
@@ -349,240 +296,6 @@ function isRepeatedFragmentCopy(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]): boolean {
|
|
||||||
if (events.length < 2 || !events.every((event) => fragmentPlacementAnchors(event).size > 0)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestStart = events.reduce(
|
|
||||||
(latest, event) => Math.max(latest, event.startTime),
|
|
||||||
-Infinity,
|
|
||||||
);
|
|
||||||
const earliestEnd = events.reduce(
|
|
||||||
(earliest, event) => Math.min(earliest, event.endTime),
|
|
||||||
Infinity,
|
|
||||||
);
|
|
||||||
if (latestStart >= earliestEnd) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = events[0]!;
|
|
||||||
const hasChangingOverrides = events.some(
|
|
||||||
(event) => event.overrideSignature !== first.overrideSignature,
|
|
||||||
);
|
|
||||||
const hasPositionedLayerCopy = events.some((event, index) =>
|
|
||||||
events
|
|
||||||
.slice(0, index)
|
|
||||||
.some(
|
|
||||||
(previous) =>
|
|
||||||
compactCueMatchText(previous) === compactCueMatchText(event) &&
|
|
||||||
isRepeatedFragmentCopy(previous, event),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return hasChangingOverrides || hasPositionedLayerCopy;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssFragmentPart {
|
|
||||||
cue: AnnotatedSubtitleCue;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconstructedAssFragmentLayout(
|
|
||||||
parts: readonly AssFragmentPart[],
|
|
||||||
owner: AnnotatedSubtitleCue,
|
|
||||||
): AssCueLayout | undefined {
|
|
||||||
let positionedPartCount = 0;
|
|
||||||
let minimumY = Infinity;
|
|
||||||
let maximumY = -Infinity;
|
|
||||||
for (const part of parts) {
|
|
||||||
const layout = part.cue.assLayout;
|
|
||||||
if (layout?.kind !== 'positioned') continue;
|
|
||||||
positionedPartCount += 1;
|
|
||||||
minimumY = Math.min(minimumY, layout.y);
|
|
||||||
maximumY = Math.max(maximumY, layout.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
|
|
||||||
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
|
|
||||||
) {
|
|
||||||
return { kind: 'fragment-grid', sourceOrder: owner.order };
|
|
||||||
}
|
|
||||||
return owner.assLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssFragmentTimingCluster {
|
|
||||||
events: AnnotatedSubtitleCue[];
|
|
||||||
minStartTime: number;
|
|
||||||
maxStartTime: number;
|
|
||||||
minEndTime: number;
|
|
||||||
maxEndTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToFragmentTimingCluster(
|
|
||||||
cluster: AssFragmentTimingCluster,
|
|
||||||
cue: AnnotatedSubtitleCue,
|
|
||||||
): void {
|
|
||||||
cluster.events.push(cue);
|
|
||||||
cluster.minStartTime = Math.min(cluster.minStartTime, cue.startTime);
|
|
||||||
cluster.maxStartTime = Math.max(cluster.maxStartTime, cue.startTime);
|
|
||||||
cluster.minEndTime = Math.min(cluster.minEndTime, cue.endTime);
|
|
||||||
cluster.maxEndTime = Math.max(cluster.maxEndTime, cue.endTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fragmentTimingDistance(
|
|
||||||
cluster: AssFragmentTimingCluster,
|
|
||||||
cue: AnnotatedSubtitleCue,
|
|
||||||
): number {
|
|
||||||
const nextMinStart = Math.min(cluster.minStartTime, cue.startTime);
|
|
||||||
const nextMaxStart = Math.max(cluster.maxStartTime, cue.startTime);
|
|
||||||
const nextMinEnd = Math.min(cluster.minEndTime, cue.endTime);
|
|
||||||
const nextMaxEnd = Math.max(cluster.maxEndTime, cue.endTime);
|
|
||||||
if (
|
|
||||||
nextMaxStart - nextMinStart > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS ||
|
|
||||||
nextMaxEnd - nextMinEnd > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS
|
|
||||||
) {
|
|
||||||
return Infinity;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
Math.abs(cue.startTime - (cluster.minStartTime + cluster.maxStartTime) / 2) +
|
|
||||||
Math.abs(cue.endTime - (cluster.minEndTime + cluster.maxEndTime) / 2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clusterAssFragmentEvents(
|
|
||||||
events: readonly AnnotatedSubtitleCue[],
|
|
||||||
): AssFragmentTimingCluster[] {
|
|
||||||
const clusters: AssFragmentTimingCluster[] = [];
|
|
||||||
for (const cue of events) {
|
|
||||||
let nearest: AssFragmentTimingCluster | null = null;
|
|
||||||
let nearestDistance = Infinity;
|
|
||||||
for (const cluster of clusters) {
|
|
||||||
const distance = fragmentTimingDistance(cluster, cue);
|
|
||||||
if (distance < nearestDistance) {
|
|
||||||
nearest = cluster;
|
|
||||||
nearestDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nearest) {
|
|
||||||
addToFragmentTimingCluster(nearest, cue);
|
|
||||||
} else {
|
|
||||||
clusters.push({
|
|
||||||
events: [cue],
|
|
||||||
minStartTime: cue.startTime,
|
|
||||||
maxStartTime: cue.startTime,
|
|
||||||
minEndTime: cue.endTime,
|
|
||||||
maxEndTime: cue.endTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clusters;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
|
|
||||||
const visibleLines = decodeSubtitleCueText(cue.rawText)
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.trim().length > 0);
|
|
||||||
return visibleLines.length === 1 ? visibleLines[0]! : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconstructAssFragmentLine(
|
|
||||||
events: readonly AnnotatedSubtitleCue[],
|
|
||||||
): AnnotatedSubtitleCue | null {
|
|
||||||
const hasRelaxedEvidence = hasRelaxedAssFragmentEvidence(events);
|
|
||||||
const minimumEvents = hasRelaxedEvidence ? 2 : MIN_FRAGMENT_LINE_EVENTS;
|
|
||||||
if (events.length < minimumEvents || !hasAssAnimationEvidence(events)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: AssFragmentPart[] = [];
|
|
||||||
for (const cue of events) {
|
|
||||||
const text = decodeSingleAssFragment(cue);
|
|
||||||
if (text === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const compactText = compactAssMatchText(text);
|
|
||||||
const isLayerCopy = parts.some(
|
|
||||||
(part) =>
|
|
||||||
compactAssMatchText(part.text) === compactText && isRepeatedFragmentCopy(part.cue, cue),
|
|
||||||
);
|
|
||||||
if (!isLayerCopy) {
|
|
||||||
parts.push({ cue, text });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const minimumParts = hasRelaxedEvidence ? 1 : MIN_FRAGMENT_LINE_PARTS;
|
|
||||||
if (parts.length < minimumParts || (!hasRelaxedEvidence && parts.length === events.length)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const lengths = parts
|
|
||||||
.map((part) => compactAssMatchText(part.text).length)
|
|
||||||
.sort((left, right) => left - right);
|
|
||||||
if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = parts
|
|
||||||
.map((part) => part.text)
|
|
||||||
.join('')
|
|
||||||
.trim();
|
|
||||||
if (!text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const owner = parts[0]!.cue;
|
|
||||||
const animationStartTime = earliestStartTime(events);
|
|
||||||
const animationEndTime = latestEndTime(events);
|
|
||||||
return {
|
|
||||||
...owner,
|
|
||||||
startTime: animationStartTime,
|
|
||||||
endTime: animationEndTime,
|
|
||||||
text,
|
|
||||||
rawText: text,
|
|
||||||
source: 'reconstructed-ass',
|
|
||||||
animationStartTime,
|
|
||||||
animationEndTime,
|
|
||||||
assLayout: reconstructedAssFragmentLayout(parts, owner),
|
|
||||||
overrides: [],
|
|
||||||
overrideSignature: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
|
||||||
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
|
||||||
for (const cue of dialogue) {
|
|
||||||
if (cue.source !== undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = assEventGroupKey(cue);
|
|
||||||
const group = groups.get(key);
|
|
||||||
if (group) {
|
|
||||||
group.push(cue);
|
|
||||||
} else {
|
|
||||||
groups.set(key, [cue]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recovered: AnnotatedSubtitleCue[] = [];
|
|
||||||
const suppressed = new Set<AnnotatedSubtitleCue>();
|
|
||||||
for (const events of groups.values()) {
|
|
||||||
for (const cluster of clusterAssFragmentEvents(events)) {
|
|
||||||
const line = reconstructAssFragmentLine(cluster.events);
|
|
||||||
if (!line) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
recovered.push(line);
|
|
||||||
cluster.events.forEach((event) => suppressed.add(event));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (recovered.length === 0) {
|
|
||||||
return dialogue;
|
|
||||||
}
|
|
||||||
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
|
||||||
(left, right) =>
|
|
||||||
left.startTime - right.startTime || left.endTime - right.endTime || left.order - right.order,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
||||||
const groups: FragmentGroup[] = [];
|
const groups: FragmentGroup[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -794,37 +507,6 @@ function recoverCanonicalAssEvents({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAssCoordinate(value: string | undefined): number | null {
|
|
||||||
if (!value?.trim()) return null;
|
|
||||||
const coordinate = Number(value.trim());
|
|
||||||
return Number.isFinite(coordinate) ? coordinate : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAssCueLayout(
|
|
||||||
overrides: readonly AssOverrideCommand[],
|
|
||||||
sourceOrder: number,
|
|
||||||
): AssCueLayout {
|
|
||||||
let y: number | null = null;
|
|
||||||
for (const command of overrides) {
|
|
||||||
if (command.animated) continue;
|
|
||||||
const name = command.name.toLowerCase();
|
|
||||||
const args = command.args.split(',');
|
|
||||||
if (name === 'pos') {
|
|
||||||
y = parseAssCoordinate(args[1]) ?? y;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (name !== 'move') continue;
|
|
||||||
const startY = parseAssCoordinate(args[1]);
|
|
||||||
const endY = parseAssCoordinate(args[3]);
|
|
||||||
if (startY !== null && endY !== null) {
|
|
||||||
y = (startY + endY) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return y === null
|
|
||||||
? { kind: 'source-order', sourceOrder }
|
|
||||||
: { kind: 'positioned', sourceOrder, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||||
const cues: AnnotatedSubtitleCue[] = [];
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const comments: AnnotatedSubtitleCue[] = [];
|
const comments: AnnotatedSubtitleCue[] = [];
|
||||||
@@ -853,10 +535,6 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
// Event text can end in an authored space. Fragmented karaoke commonly uses that
|
|
||||||
// space to retain word boundaries when its separately positioned events are joined
|
|
||||||
// back into a line, so only remove indentation before slicing the event fields.
|
|
||||||
const eventLine = line.trimStart();
|
|
||||||
|
|
||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
inEventsSection = trimmed.toLowerCase() === '[events]';
|
inEventsSection = trimmed.toLowerCase() === '[events]';
|
||||||
@@ -887,9 +565,9 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventPrefix = eventLine.startsWith(ASS_DIALOGUE_PREFIX)
|
const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX)
|
||||||
? ASS_DIALOGUE_PREFIX
|
? ASS_DIALOGUE_PREFIX
|
||||||
: eventLine.startsWith(ASS_COMMENT_PREFIX)
|
: trimmed.startsWith(ASS_COMMENT_PREFIX)
|
||||||
? ASS_COMMENT_PREFIX
|
? ASS_COMMENT_PREFIX
|
||||||
: null;
|
: null;
|
||||||
if (!eventPrefix) {
|
if (!eventPrefix) {
|
||||||
@@ -900,7 +578,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = eventLine.slice(eventPrefix.length).split(',');
|
const fields = trimmed.slice(eventPrefix.length).split(',');
|
||||||
if (
|
if (
|
||||||
fieldIndex.start >= fields.length ||
|
fieldIndex.start >= fields.length ||
|
||||||
fieldIndex.end >= fields.length ||
|
fieldIndex.end >= fields.length ||
|
||||||
@@ -937,7 +615,6 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
overrides,
|
overrides,
|
||||||
overrideSignature: assOverrideSignature(overrides),
|
overrideSignature: assOverrideSignature(overrides),
|
||||||
order: eventOrder,
|
order: eventOrder,
|
||||||
assLayout: buildAssCueLayout(overrides, eventOrder),
|
|
||||||
};
|
};
|
||||||
eventOrder += 1;
|
eventOrder += 1;
|
||||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||||
@@ -951,7 +628,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
|
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssCues(content: string): SubtitleCue[] {
|
export function parseAssCues(content: string): SubtitleCue[] {
|
||||||
|
|||||||
@@ -606,3 +606,93 @@ test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', ()
|
|||||||
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
|
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
|
||||||
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
|
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths isolates development runs from the production profile', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: (name) => calls.push(`name:${name}`),
|
||||||
|
setPath: (key, value) => calls.push(`path:${key}:${value}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
xdgConfigHome: '/tmp/xdg',
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--start', '--dev'],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, '/tmp/xdg/SubMiner-dev');
|
||||||
|
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner-dev']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => {
|
||||||
|
for (const forwardedFlag of ['--dev', '--debug']) {
|
||||||
|
let selectedPath = '';
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: () => {},
|
||||||
|
setPath: (_key, value) => {
|
||||||
|
selectedPath = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
xdgConfigHome: '/tmp/xdg',
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--launch-mpv', forwardedFlag],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
|
||||||
|
assert.equal(selectedPath, '/tmp/xdg/SubMiner');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths uses the supplied environment for config discovery', () => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: () => {},
|
||||||
|
setPath: (_key, value) => paths.push(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--start'],
|
||||||
|
env: { XDG_CONFIG_HOME: '/tmp/injected-xdg' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, '/tmp/injected-xdg/SubMiner');
|
||||||
|
assert.deepEqual(paths, ['/tmp/injected-xdg/SubMiner']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths allows an explicit production-profile development run', () => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: () => {},
|
||||||
|
setPath: (_key, value) => paths.push(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'win32',
|
||||||
|
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||||
|
homeDir: 'C:\\Users\\tester',
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron.exe', '.', '--debug'],
|
||||||
|
env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, 'C:\\Users\\tester\\AppData\\Roaming\\SubMiner');
|
||||||
|
assert.deepEqual(paths, ['C:\\Users\\tester\\AppData\\Roaming\\SubMiner']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||||
import { resolveConfigDir } from './config/path-resolution';
|
import { resolveConfigDir } from './config/path-resolution';
|
||||||
@@ -14,6 +15,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
|||||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||||
const APP_NAME = 'SubMiner';
|
const APP_NAME = 'SubMiner';
|
||||||
|
const DEVELOPMENT_APP_NAME = 'SubMiner-dev';
|
||||||
|
export const USE_PRODUCTION_PROFILE_ENV = 'SUBMINER_USE_PRODUCTION_PROFILE';
|
||||||
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
||||||
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||||
@@ -53,6 +56,8 @@ type EarlyAppPathOptions = {
|
|||||||
xdgConfigHome?: string;
|
xdgConfigHome?: string;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
existsSync?: (candidate: string) => boolean;
|
existsSync?: (candidate: string) => boolean;
|
||||||
|
argv?: string[];
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||||
@@ -252,13 +257,25 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
||||||
const userDataPath = resolveConfigDir({
|
const platform = options?.platform ?? process.platform;
|
||||||
platform: options?.platform ?? process.platform,
|
const env = options?.env ?? process.env;
|
||||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
const configDir = resolveConfigDir({
|
||||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
platform,
|
||||||
|
appDataDir: options?.appDataDir ?? env.APPDATA,
|
||||||
|
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
|
||||||
homeDir: options?.homeDir ?? os.homedir(),
|
homeDir: options?.homeDir ?? os.homedir(),
|
||||||
existsSync: options?.existsSync ?? fs.existsSync,
|
existsSync: options?.existsSync ?? fs.existsSync,
|
||||||
});
|
});
|
||||||
|
const argv = options?.argv ?? process.argv;
|
||||||
|
const launchMpvIndex = argv.indexOf('--launch-mpv');
|
||||||
|
const appArgv = launchMpvIndex === -1 ? argv : argv.slice(0, launchMpvIndex);
|
||||||
|
const useDevelopmentProfile =
|
||||||
|
(appArgv.includes('--dev') || appArgv.includes('--debug')) &&
|
||||||
|
env[USE_PRODUCTION_PROFILE_ENV] !== '1';
|
||||||
|
const platformPath = platform === 'win32' ? path.win32 : path.posix;
|
||||||
|
const userDataPath = useDevelopmentProfile
|
||||||
|
? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME)
|
||||||
|
: configDir;
|
||||||
|
|
||||||
app.setName(APP_NAME);
|
app.setName(APP_NAME);
|
||||||
app.setPath('userData', userDataPath);
|
app.setPath('userData', userDataPath);
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
spawnDetachedApp,
|
spawnDetachedApp,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import {
|
||||||
|
requestSingleInstanceLockEarly,
|
||||||
|
shouldBypassSingleInstanceLockForArgv,
|
||||||
|
} from './main/early-single-instance';
|
||||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +38,7 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
|
|||||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||||
import { handleSyncCliAtEntry } from './main/sync-cli';
|
import { handleSyncCliAtEntry } from './main/sync-cli';
|
||||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||||
|
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
|
||||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||||
import {
|
import {
|
||||||
applyLogFileTogglesToEnv,
|
applyLogFileTogglesToEnv,
|
||||||
@@ -192,11 +196,25 @@ registerFatalErrorHandlers({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function startMainProcess(): void {
|
function startMainProcess(): void {
|
||||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
// Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon
|
||||||
|
// commands keep their existing lock bypass when Electron runs in Node mode.
|
||||||
|
const gotSingleInstanceLock =
|
||||||
|
shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app);
|
||||||
if (!gotSingleInstanceLock) {
|
if (!gotSingleInstanceLock) {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runtimeGuard = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: process.versions.electron ?? '',
|
||||||
|
userDataPath,
|
||||||
|
});
|
||||||
|
if (!runtimeGuard.ok) {
|
||||||
|
console.error(runtimeGuard.details);
|
||||||
|
dialog.showErrorBox(runtimeGuard.title, runtimeGuard.details);
|
||||||
|
app.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
require('./main.js');
|
require('./main.js');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtim
|
|||||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||||
|
import {
|
||||||
|
assertYomitanDictionaryMutationSafe,
|
||||||
|
observeYomitanDictionaryCount,
|
||||||
|
} from './main/runtime/yomitan-dictionary-integrity';
|
||||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||||
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||||
import {
|
import {
|
||||||
@@ -312,7 +316,6 @@ import {
|
|||||||
promoteSettingsWindowAboveOverlay,
|
promoteSettingsWindowAboveOverlay,
|
||||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||||
replayCurrentSubtitleRuntime,
|
replayCurrentSubtitleRuntime,
|
||||||
resolveSanitizedSubtitleSeekCommand,
|
|
||||||
resolveJellyfinPlaybackPlanRuntime,
|
resolveJellyfinPlaybackPlanRuntime,
|
||||||
runStartupBootstrapRuntime,
|
runStartupBootstrapRuntime,
|
||||||
saveJellyfinSubtitleDelay,
|
saveJellyfinSubtitleDelay,
|
||||||
@@ -588,10 +591,9 @@ import {
|
|||||||
import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source';
|
import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source';
|
||||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||||
import {
|
import {
|
||||||
createCachedInternalSubtitleTrackExtractor,
|
|
||||||
loadSubtitleSourceText,
|
loadSubtitleSourceText,
|
||||||
|
extractInternalSubtitleTrackToTempFile,
|
||||||
} from './main/runtime/internal-subtitle-extraction';
|
} from './main/runtime/internal-subtitle-extraction';
|
||||||
import { createRemoteMediaPathDetector } from './main/runtime/network-media-path';
|
|
||||||
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
|
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
|
||||||
import { getSubsyncConfig } from './subsync/utils';
|
import { getSubsyncConfig } from './subsync/utils';
|
||||||
|
|
||||||
@@ -766,6 +768,7 @@ type BootServices = MainBootServicesResult<
|
|||||||
const bootServices = createMainBootServices({
|
const bootServices = createMainBootServices({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
argv: process.argv,
|
argv: process.argv,
|
||||||
|
configDir: app.getPath('userData'),
|
||||||
appDataDir: process.env.APPDATA,
|
appDataDir: process.env.APPDATA,
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
homeDir: os.homedir(),
|
homeDir: os.homedir(),
|
||||||
@@ -1434,6 +1437,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
error: (message, ...args) => logger.error(message, ...args),
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
info: (message, ...args) => logger.info(message, ...args),
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
});
|
});
|
||||||
|
const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length);
|
||||||
|
if (!integrity.safe) {
|
||||||
|
logger.error(`[dictionary:integrity] ${integrity.message}`);
|
||||||
|
}
|
||||||
return dictionaries.length;
|
return dictionaries.length;
|
||||||
},
|
},
|
||||||
isExternalYomitanConfigured: () =>
|
isExternalYomitanConfigured: () =>
|
||||||
@@ -1960,31 +1967,6 @@ let linuxVisibleOverlayOwnerBindingKey: string | null = null;
|
|||||||
let linuxVisibleOverlayWindowModeSwitchToken = 0;
|
let linuxVisibleOverlayWindowModeSwitchToken = 0;
|
||||||
let subtitleSidebarRequestedOpen = false;
|
let subtitleSidebarRequestedOpen = false;
|
||||||
const SEEK_THRESHOLD_SECONDS = 3;
|
const SEEK_THRESHOLD_SECONDS = 3;
|
||||||
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
|
|
||||||
let explicitSeekIntentExpiresAtMs = 0;
|
|
||||||
|
|
||||||
function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean {
|
|
||||||
return command[0] === 'seek' || command[0] === 'sub-seek';
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
|
||||||
const command =
|
|
||||||
resolveSanitizedSubtitleSeekCommand(
|
|
||||||
rawCommand,
|
|
||||||
appState.activeParsedSubtitleCues,
|
|
||||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
|
||||||
) ?? rawCommand;
|
|
||||||
if (isExplicitMpvSeekCommand(command)) {
|
|
||||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
|
||||||
}
|
|
||||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
|
||||||
}
|
|
||||||
|
|
||||||
function consumeExplicitSeekIntent(): boolean {
|
|
||||||
const pending = explicitSeekIntentExpiresAtMs >= Date.now();
|
|
||||||
explicitSeekIntentExpiresAtMs = 0;
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
||||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
@@ -2055,13 +2037,10 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor();
|
|
||||||
const detectRemoteMediaPath = createRemoteMediaPathDetector();
|
|
||||||
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
|
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
|
||||||
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
||||||
isRemoteMediaPath: detectRemoteMediaPath,
|
|
||||||
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
||||||
cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
|
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||||
logDebug: (message) => logger.debug(message),
|
logDebug: (message) => logger.debug(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2090,8 +2069,8 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
|||||||
// Remote media has no extractable on-disk track to fall back to, so a transient
|
// Remote media has no extractable on-disk track to fall back to, so a transient
|
||||||
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
|
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
|
||||||
// otherwise drop a working cue list for the rest of the episode.
|
// otherwise drop a working cue list for the rest of the episode.
|
||||||
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
|
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
|
||||||
isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)),
|
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
|
||||||
subtitlePrefetchInitController,
|
subtitlePrefetchInitController,
|
||||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||||
logDebug: (message) => logger.debug(message),
|
logDebug: (message) => logger.debug(message),
|
||||||
@@ -2553,10 +2532,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
),
|
),
|
||||||
getYomitanDictionaryInfo: async () => {
|
getYomitanDictionaryInfo: async () => {
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||||
error: (message, ...args) => logger.error(message, ...args),
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
info: (message, ...args) => logger.info(message, ...args),
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
});
|
});
|
||||||
|
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
|
||||||
|
return dictionaries;
|
||||||
},
|
},
|
||||||
importYomitanDictionary: async (zipPath) => {
|
importYomitanDictionary: async (zipPath) => {
|
||||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
@@ -2653,12 +2634,6 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
onIndexReady: () => refreshCurrentSubtitleAnnotations(),
|
|
||||||
onIndexReadyError: (error) =>
|
|
||||||
logger.warn(
|
|
||||||
'Failed to refresh subtitle annotations after character portrait index became ready.',
|
|
||||||
error,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
||||||
@@ -3966,7 +3941,6 @@ const {
|
|||||||
appState.yomitanSettingsWindow = null;
|
appState.yomitanSettingsWindow = null;
|
||||||
},
|
},
|
||||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||||
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||||
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||||
@@ -4084,7 +4058,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
|||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||||
};
|
};
|
||||||
function refreshCurrentSubtitleAnnotations(): void {
|
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
||||||
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
||||||
if (hasCurrentSubtitle) {
|
if (hasCurrentSubtitle) {
|
||||||
subtitlePrefetchService?.pause();
|
subtitlePrefetchService?.pause();
|
||||||
@@ -4095,7 +4069,7 @@ function refreshCurrentSubtitleAnnotations(): void {
|
|||||||
// Idle controller: no settle is coming to release the pause above.
|
// Idle controller: no settle is coming to release the pause above.
|
||||||
subtitlePrefetchService?.resume();
|
subtitlePrefetchService?.resume();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
let hasAttemptedImmersionTrackerStartup = false;
|
let hasAttemptedImmersionTrackerStartup = false;
|
||||||
const ensureImmersionTrackerStarted = (): void => {
|
const ensureImmersionTrackerStarted = (): void => {
|
||||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||||
@@ -4527,7 +4501,6 @@ const {
|
|||||||
appState.activeParsedSubtitleMediaPath,
|
appState.activeParsedSubtitleMediaPath,
|
||||||
);
|
);
|
||||||
if ((normalizedPath || null) !== previousPath) {
|
if ((normalizedPath || null) !== previousPath) {
|
||||||
cachedInternalSubtitleTrackExtractor.clear();
|
|
||||||
secondarySubtitleTrackController.reset();
|
secondarySubtitleTrackController.reset();
|
||||||
const resetSubtitlePayload = { text: '', tokens: null };
|
const resetSubtitlePayload = { text: '', tokens: null };
|
||||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||||
@@ -4612,7 +4585,6 @@ const {
|
|||||||
reportJellyfinRemoteProgress: (forceImmediate) => {
|
reportJellyfinRemoteProgress: (forceImmediate) => {
|
||||||
void reportJellyfinRemoteProgress(forceImmediate);
|
void reportJellyfinRemoteProgress(forceImmediate);
|
||||||
},
|
},
|
||||||
consumeExplicitSeek: () => consumeExplicitSeekIntent(),
|
|
||||||
onTimePosUpdate: (time) => {
|
onTimePosUpdate: (time) => {
|
||||||
const delta = time - lastObservedTimePos;
|
const delta = time - lastObservedTimePos;
|
||||||
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
|
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
|
||||||
@@ -5120,7 +5092,9 @@ function initializeOverlayRuntime(): void {
|
|||||||
overlayModalRuntime.primeModalWindow();
|
overlayModalRuntime.primeModalWindow();
|
||||||
}
|
}
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
@@ -5518,7 +5492,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
sendMpvCommand: (rawCommand: (string | number)[]) => sendRendererMpvCommand(rawCommand),
|
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||||
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
||||||
@@ -5912,7 +5887,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration = integration;
|
appState.ankiIntegration = integration;
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
refreshCurrentSubtitleAnnotations,
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||||
consumePendingSubtitleMiningContext,
|
consumePendingSubtitleMiningContext,
|
||||||
|
|||||||
@@ -138,3 +138,56 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
|
||||||
|
const services = createMainBootServices({
|
||||||
|
platform: 'linux',
|
||||||
|
argv: ['electron', '.', '--dev'],
|
||||||
|
configDir: '/tmp/SubMiner-dev',
|
||||||
|
appDataDir: undefined,
|
||||||
|
xdgConfigHome: undefined,
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
defaultMpvLogFile: '/tmp/default.log',
|
||||||
|
envMpvLog: undefined,
|
||||||
|
defaultTexthookerPort: 5174,
|
||||||
|
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||||
|
resolveConfigDir: () => {
|
||||||
|
throw new Error('early profile should be authoritative');
|
||||||
|
},
|
||||||
|
existsSync: () => false,
|
||||||
|
mkdirSync: () => {},
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
app: {
|
||||||
|
setPath: () => {},
|
||||||
|
quit: () => {},
|
||||||
|
exit: () => {},
|
||||||
|
on: () => ({}),
|
||||||
|
whenReady: async () => {},
|
||||||
|
},
|
||||||
|
shouldBypassSingleInstanceLock: () => false,
|
||||||
|
requestSingleInstanceLockEarly: () => true,
|
||||||
|
registerSecondInstanceHandlerEarly: () => {},
|
||||||
|
onConfigStartupParseError: () => {},
|
||||||
|
createConfigService: (configDir) => ({ configDir }),
|
||||||
|
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||||
|
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||||
|
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||||
|
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
|
||||||
|
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||||
|
createMainRuntimeRegistry: () => ({}),
|
||||||
|
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||||
|
createOverlayModalInputState: () => ({
|
||||||
|
getModalInputExclusive: () => false,
|
||||||
|
handleModalInputStateChange: () => {},
|
||||||
|
}),
|
||||||
|
createOverlayContentMeasurementStore: () => ({}),
|
||||||
|
getSyncOverlayShortcutsForModal: () => () => {},
|
||||||
|
getSyncOverlayVisibilityForModal: () => () => {},
|
||||||
|
createOverlayModalRuntime: () => ({}),
|
||||||
|
createAppState: (input) => input,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(services.configDir, '/tmp/SubMiner-dev');
|
||||||
|
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
|
||||||
|
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface MainBootServicesParams<
|
|||||||
> {
|
> {
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
argv: string[];
|
argv: string[];
|
||||||
|
configDir?: string;
|
||||||
appDataDir: string | undefined;
|
appDataDir: string | undefined;
|
||||||
xdgConfigHome: string | undefined;
|
xdgConfigHome: string | undefined;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
@@ -174,13 +175,15 @@ export function createMainBootServices<
|
|||||||
TAppState,
|
TAppState,
|
||||||
TAppLifecycleApp
|
TAppLifecycleApp
|
||||||
> {
|
> {
|
||||||
const configDir = params.resolveConfigDir({
|
const configDir =
|
||||||
platform: params.platform,
|
params.configDir ??
|
||||||
appDataDir: params.appDataDir,
|
params.resolveConfigDir({
|
||||||
xdgConfigHome: params.xdgConfigHome,
|
platform: params.platform,
|
||||||
homeDir: params.homeDir,
|
appDataDir: params.appDataDir,
|
||||||
existsSync: params.existsSync,
|
xdgConfigHome: params.xdgConfigHome,
|
||||||
});
|
homeDir: params.homeDir,
|
||||||
|
existsSync: params.existsSync,
|
||||||
|
});
|
||||||
const userDataPath = configDir;
|
const userDataPath = configDir;
|
||||||
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
|
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
|
||||||
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
|
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||||
const nameSplitResolution = nameSplitTokenizerAvailable
|
const resolvedNameSplits = nameSplitTokenizerAvailable
|
||||||
? await resolveJapaneseNameSplits(
|
? await resolveJapaneseNameSplits(
|
||||||
characters,
|
characters,
|
||||||
deps.tokenizeJapaneseName!,
|
deps.tokenizeJapaneseName!,
|
||||||
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const resolvedNameSplits = nameSplitResolution?.splits;
|
const nameSplitSource =
|
||||||
const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
|
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
||||||
|
|
||||||
progress?.onGenerateProgress?.({
|
progress?.onGenerateProgress?.({
|
||||||
mediaId,
|
mediaId,
|
||||||
|
|||||||
@@ -198,66 +198,6 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
|
|||||||
assert.equal(scoped.alt, 'Kazuma');
|
assert.equal(scoped.alt, 'Kazuma');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => {
|
|
||||||
const outputDir = makeTempDir();
|
|
||||||
const snapshot: CharacterDictionarySnapshot = {
|
|
||||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
|
||||||
mediaId: 21858,
|
|
||||||
mediaTitle: 'Little Witch Academia',
|
|
||||||
entryCount: 1,
|
|
||||||
updatedAt: 1_700_000_000_000,
|
|
||||||
termEntries: [
|
|
||||||
[
|
|
||||||
'ダイアナ',
|
|
||||||
'だいあな',
|
|
||||||
'name primary',
|
|
||||||
'',
|
|
||||||
75,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: 'structured-content',
|
|
||||||
content: {
|
|
||||||
tag: 'img',
|
|
||||||
path: 'img/m21858-c81709.png',
|
|
||||||
alt: 'ダイアナ・キャベンディッシュ',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
0,
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }],
|
|
||||||
};
|
|
||||||
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
|
||||||
const callbackError = new Error('annotation refresh failed');
|
|
||||||
const reportingError = new Error('error reporter failed');
|
|
||||||
let readyCount = 0;
|
|
||||||
const reportedErrors: unknown[] = [];
|
|
||||||
const lookup = createCharacterDictionaryImageLookup({
|
|
||||||
outputDir,
|
|
||||||
onIndexReady: () => {
|
|
||||||
readyCount += 1;
|
|
||||||
if (readyCount === 1) {
|
|
||||||
throw callbackError;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onIndexReadyError: (error) => {
|
|
||||||
reportedErrors.push(error);
|
|
||||||
throw reportingError;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null);
|
|
||||||
await waitForRefresh(() => (reportedErrors.length === 1 ? true : null));
|
|
||||||
assert.ok(lookup.get('ダイアナ', snapshot.mediaId));
|
|
||||||
|
|
||||||
assert.equal(readyCount, 2);
|
|
||||||
assert.deepEqual(reportedErrors, [callbackError]);
|
|
||||||
lookup.get('ダイアナ', snapshot.mediaId);
|
|
||||||
assert.equal(readyCount, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
|
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
|
||||||
const outputDir = makeTempDir();
|
const outputDir = makeTempDir();
|
||||||
const snapshot: CharacterDictionarySnapshot = {
|
const snapshot: CharacterDictionarySnapshot = {
|
||||||
|
|||||||
@@ -218,8 +218,6 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
userDataPath?: string;
|
userDataPath?: string;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
getCurrentMediaId?: () => number | null | undefined;
|
getCurrentMediaId?: () => number | null | undefined;
|
||||||
onIndexReady?: () => void;
|
|
||||||
onIndexReadyError?: (error: unknown) => void;
|
|
||||||
}): {
|
}): {
|
||||||
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
@@ -231,24 +229,6 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
let index = new Map<string, CharacterNameImage>();
|
let index = new Map<string, CharacterNameImage>();
|
||||||
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
let indexReadyDeliveryPending = false;
|
|
||||||
|
|
||||||
function deliverIndexReadyIfPending(): void {
|
|
||||||
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
indexReadyDeliveryPending = false;
|
|
||||||
try {
|
|
||||||
deps.onIndexReady();
|
|
||||||
} catch (error) {
|
|
||||||
indexReadyDeliveryPending = true;
|
|
||||||
try {
|
|
||||||
deps.onIndexReadyError?.(error);
|
|
||||||
} catch {
|
|
||||||
// Error reporting must not reject the detached index refresh task.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
|
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
|
||||||
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
||||||
@@ -261,7 +241,6 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
signature = '';
|
signature = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deliverIndexReadyIfPending();
|
|
||||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||||
if (nextSignature === signature || refreshInFlight) {
|
if (nextSignature === signature || refreshInFlight) {
|
||||||
return;
|
return;
|
||||||
@@ -283,8 +262,6 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
index = nextIndex;
|
index = nextIndex;
|
||||||
indexByMediaId = nextIndexByMediaId;
|
indexByMediaId = nextIndexByMediaId;
|
||||||
signature = nextSignature;
|
signature = nextSignature;
|
||||||
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
|
|
||||||
deliverIndexReadyIfPending();
|
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.kind, 'complete');
|
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
||||||
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
||||||
@@ -65,8 +64,7 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.kind, 'complete');
|
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
||||||
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
||||||
@@ -87,8 +85,7 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.kind, 'complete');
|
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
||||||
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
||||||
@@ -99,8 +96,7 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.kind, 'complete');
|
assert.equal(splits.size, 0);
|
||||||
assert.equal(splits.splits.size, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
||||||
@@ -121,8 +117,7 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.kind, 'complete');
|
assert.equal(splits.size, 0);
|
||||||
assert.equal(splits.splits.size, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
||||||
@@ -135,8 +130,7 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
|||||||
(message) => warnings.push(message),
|
(message) => warnings.push(message),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.kind, 'incomplete');
|
assert.equal(splits.size, 0);
|
||||||
assert.equal(splits.splits.size, 0);
|
|
||||||
assert.equal(warnings.length, 1);
|
assert.equal(warnings.length, 1);
|
||||||
assert.match(warnings[0]!, /mecab unavailable/);
|
assert.match(warnings[0]!, /mecab unavailable/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ import type {
|
|||||||
ResolvedNameSplit,
|
ResolvedNameSplit,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type JapaneseNameSplitResolution =
|
|
||||||
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
|
|
||||||
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
|
|
||||||
|
|
||||||
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
||||||
|
|
||||||
function joinSurfaces(tokens: NameSplitToken[]): string {
|
function joinSurfaces(tokens: NameSplitToken[]): string {
|
||||||
@@ -91,9 +87,8 @@ export async function resolveJapaneseNameSplits(
|
|||||||
tokenize: NameSplitTokenizer,
|
tokenize: NameSplitTokenizer,
|
||||||
logWarn?: (message: string) => void,
|
logWarn?: (message: string) => void,
|
||||||
onCharacterResolved?: (completed: number, total: number) => void,
|
onCharacterResolved?: (completed: number, total: number) => void,
|
||||||
): Promise<JapaneseNameSplitResolution> {
|
): Promise<Map<string, ResolvedNameSplit>> {
|
||||||
const splits = new Map<string, ResolvedNameSplit>();
|
const splits = new Map<string, ResolvedNameSplit>();
|
||||||
let tokenizerFailed = false;
|
|
||||||
let resolvedCharacters = 0;
|
let resolvedCharacters = 0;
|
||||||
for (const character of characters) {
|
for (const character of characters) {
|
||||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||||
@@ -104,17 +99,12 @@ export async function resolveJapaneseNameSplits(
|
|||||||
try {
|
try {
|
||||||
tokens = await tokenize(name);
|
tokens = await tokenize(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tokenizerFailed = true;
|
|
||||||
logWarn?.(
|
logWarn?.(
|
||||||
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!tokens) {
|
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
||||||
tokenizerFailed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
|
||||||
const splitIndex =
|
const splitIndex =
|
||||||
splitIndexFromPersonNamePos(tokens) ??
|
splitIndexFromPersonNamePos(tokens) ??
|
||||||
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
||||||
@@ -128,5 +118,5 @@ export async function resolveJapaneseNameSplits(
|
|||||||
resolvedCharacters += 1;
|
resolvedCharacters += 1;
|
||||||
onCharacterResolved?.(resolvedCharacters, characters.length);
|
onCharacterResolved?.(resolvedCharacters, characters.length);
|
||||||
}
|
}
|
||||||
return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
|
return splits;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import test from 'node:test';
|
|||||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
|
import type { CharacterDictionarySnapshot } from './types';
|
||||||
|
|
||||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
const PNG_1X1 = Buffer.from(
|
const PNG_1X1 = Buffer.from(
|
||||||
@@ -121,12 +121,7 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
|
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
||||||
characterPageRequests: number;
|
|
||||||
firstResultFromCache: boolean;
|
|
||||||
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
|
|
||||||
secondResultFromCache: boolean;
|
|
||||||
}> {
|
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||||
@@ -177,6 +172,7 @@ async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokeni
|
|||||||
}) as typeof globalThis.fetch;
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let tokenizerCalls = 0;
|
||||||
const runtime = createCharacterDictionaryRuntimeService({
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
userDataPath,
|
userDataPath,
|
||||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
@@ -189,54 +185,29 @@ async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokeni
|
|||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
}),
|
}),
|
||||||
getNameMatchImagesEnabled: () => false,
|
getNameMatchImagesEnabled: () => false,
|
||||||
tokenizeJapaneseName,
|
tokenizeJapaneseName: async () => {
|
||||||
|
tokenizerCalls += 1;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
getJapaneseNameTokenizerAvailable: () => true,
|
getJapaneseNameTokenizerAvailable: () => true,
|
||||||
now: () => 1_700_000_000_500,
|
now: () => 1_700_000_000_500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstResult = await runtime.generateForCurrentMedia();
|
const result = await runtime.generateForCurrentMedia();
|
||||||
const refreshedSnapshot = JSON.parse(
|
const refreshedSnapshot = JSON.parse(
|
||||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||||
) as CharacterDictionarySnapshot;
|
) as CharacterDictionarySnapshot;
|
||||||
const secondResult = await runtime.generateForCurrentMedia();
|
|
||||||
|
|
||||||
return {
|
assert.equal(result.fromCache, false);
|
||||||
characterPageRequests,
|
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
|
||||||
firstResultFromCache: firstResult.fromCache,
|
|
||||||
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
const retriedResult = await runtime.generateForCurrentMedia();
|
||||||
secondResultFromCache: secondResult.fromCache,
|
assert.equal(retriedResult.fromCache, false);
|
||||||
};
|
assert.equal(characterPageRequests, 2);
|
||||||
|
assert.equal(tokenizerCalls, 2);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
|
||||||
let tokenizerCalls = 0;
|
|
||||||
const result = await runNameSplitRefreshScenario(async () => {
|
|
||||||
tokenizerCalls += 1;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.firstResultFromCache, false);
|
|
||||||
assert.equal(result.refreshedNameSplitSource, 'heuristic');
|
|
||||||
assert.equal(result.secondResultFromCache, false);
|
|
||||||
assert.equal(result.characterPageRequests, 2);
|
|
||||||
assert.equal(tokenizerCalls, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
|
|
||||||
let tokenizerCalls = 0;
|
|
||||||
const result = await runNameSplitRefreshScenario(async () => {
|
|
||||||
tokenizerCalls += 1;
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.firstResultFromCache, false);
|
|
||||||
assert.equal(result.refreshedNameSplitSource, 'mecab');
|
|
||||||
assert.equal(result.secondResultFromCache, true);
|
|
||||||
assert.equal(result.characterPageRequests, 1);
|
|
||||||
assert.equal(tokenizerCalls, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
|
||||||
|
|
||||||
|
function withTempDir(run: (directory: string) => void): void {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
|
||||||
|
try {
|
||||||
|
run(directory);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runtime guard major matches the pinned Electron dependency', () => {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||||
|
) as { devDependencies: { electron: string } };
|
||||||
|
|
||||||
|
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
|
||||||
|
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard records the supported Electron major', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
|
||||||
|
highestElectronMajor: 43,
|
||||||
|
lastElectronVersion: '43.4.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard rejects a runtime outside the build major without writing state', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '44.0.0',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Unsupported Electron runtime');
|
||||||
|
assert.match(result.details, /requires Electron 43/);
|
||||||
|
assert.equal(fs.existsSync(result.statePath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard rejects prerelease Electron versions without writing state', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1-beta.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'SubMiner could not verify Electron');
|
||||||
|
assert.equal(fs.existsSync(result.statePath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Electron downgrade blocked');
|
||||||
|
assert.match(result.details, /destroy Yomitan dictionaries/);
|
||||||
|
assert.equal(result.details.includes(statePath), true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||||
|
highestElectronMajor: 44,
|
||||||
|
lastElectronVersion: '44.1.0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard blocks a downgrade within the supported Electron major', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.3.0',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Electron downgrade blocked');
|
||||||
|
assert.equal(result.details.includes(statePath), true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||||
|
highestElectronMajor: 43,
|
||||||
|
lastElectronVersion: '43.4.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard fails closed when its safety record is malformed', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'SubMiner profile safety check failed');
|
||||||
|
assert.match(result.details, /invalid format/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
|
||||||
|
|
||||||
|
export const SUPPORTED_ELECTRON_MAJOR = 43;
|
||||||
|
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
|
||||||
|
|
||||||
|
type ElectronRuntimeState = {
|
||||||
|
highestElectronMajor: number;
|
||||||
|
lastElectronVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedElectronVersion = {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
patch: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidatedElectronRuntimeState = {
|
||||||
|
state: ElectronRuntimeState;
|
||||||
|
version: ParsedElectronVersion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ElectronRuntimeGuardResult =
|
||||||
|
| { ok: true; statePath: string }
|
||||||
|
| { ok: false; title: string; details: string; statePath: string };
|
||||||
|
|
||||||
|
function parseElectronVersion(version: string): ParsedElectronVersion | null {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const major = Number.parseInt(match[1]!, 10);
|
||||||
|
const minor = Number.parseInt(match[2]!, 10);
|
||||||
|
const patch = Number.parseInt(match[3]!, 10);
|
||||||
|
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (major === 0) return null;
|
||||||
|
return { major, minor, patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareElectronVersions(
|
||||||
|
left: ParsedElectronVersion,
|
||||||
|
right: ParsedElectronVersion,
|
||||||
|
): number {
|
||||||
|
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
|
||||||
|
if (!fs.existsSync(statePath)) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
|
||||||
|
const highestElectronMajor = parsed.highestElectronMajor;
|
||||||
|
const lastElectronVersion =
|
||||||
|
typeof parsed.lastElectronVersion === 'string'
|
||||||
|
? parseElectronVersion(parsed.lastElectronVersion)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
typeof highestElectronMajor !== 'number' ||
|
||||||
|
!Number.isSafeInteger(highestElectronMajor) ||
|
||||||
|
highestElectronMajor <= 0 ||
|
||||||
|
lastElectronVersion === null
|
||||||
|
) {
|
||||||
|
throw new Error('The runtime safety record has an invalid format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
highestElectronMajor,
|
||||||
|
lastElectronVersion: parsed.lastElectronVersion!,
|
||||||
|
},
|
||||||
|
version: lastElectronVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
|
||||||
|
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enforceElectronRuntimeGuard(options: {
|
||||||
|
electronVersion: string;
|
||||||
|
userDataPath: string;
|
||||||
|
supportedElectronMajor?: number;
|
||||||
|
}): ElectronRuntimeGuardResult {
|
||||||
|
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
|
||||||
|
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
|
||||||
|
const currentVersion = parseElectronVersion(options.electronVersion);
|
||||||
|
|
||||||
|
if (currentVersion === null) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'SubMiner could not verify Electron',
|
||||||
|
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentVersion.major !== supportedElectronMajor) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Unsupported Electron runtime',
|
||||||
|
details: [
|
||||||
|
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
|
||||||
|
`The current runtime is Electron ${options.electronVersion}.`,
|
||||||
|
'',
|
||||||
|
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousState: ValidatedElectronRuntimeState | null;
|
||||||
|
try {
|
||||||
|
previousState = readRuntimeState(statePath);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'SubMiner profile safety check failed',
|
||||||
|
details: [
|
||||||
|
`SubMiner could not read the runtime safety record at ${statePath}.`,
|
||||||
|
(error as Error).message,
|
||||||
|
'',
|
||||||
|
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousState &&
|
||||||
|
(currentVersion.major < previousState.state.highestElectronMajor ||
|
||||||
|
compareElectronVersions(currentVersion, previousState.version) < 0)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Electron downgrade blocked',
|
||||||
|
details: [
|
||||||
|
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
|
||||||
|
`The current runtime is Electron ${options.electronVersion}.`,
|
||||||
|
`Runtime safety record: ${statePath}.`,
|
||||||
|
'',
|
||||||
|
'Opening Chromium storage with an older Electron version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRuntimeState(statePath, {
|
||||||
|
highestElectronMajor: Math.max(
|
||||||
|
currentVersion.major,
|
||||||
|
previousState?.state.highestElectronMajor ?? 0,
|
||||||
|
),
|
||||||
|
lastElectronVersion: options.electronVersion,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'SubMiner profile safety check failed',
|
||||||
|
details: [
|
||||||
|
`SubMiner could not update the runtime safety record at ${statePath}.`,
|
||||||
|
(error as Error).message,
|
||||||
|
'',
|
||||||
|
'Yomitan storage was not loaded.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, statePath };
|
||||||
|
}
|
||||||
@@ -183,10 +183,7 @@ test('remote media keeps parsed cues when the active subtitle source cannot be r
|
|||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
assert.match(
|
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
|
||||||
actionBlock,
|
|
||||||
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
||||||
@@ -485,10 +482,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
|
|||||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -506,20 +503,6 @@ test('subtitle annotation updates invalidate prefetched tokenizations before ref
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('character portrait index readiness refreshes cached subtitle annotations', () => {
|
|
||||||
const source = readMainSource();
|
|
||||||
const lookupDeps = source.match(
|
|
||||||
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
|
|
||||||
)?.groups?.body;
|
|
||||||
|
|
||||||
assert.ok(lookupDeps);
|
|
||||||
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
|
|
||||||
assert.match(
|
|
||||||
lookupDeps,
|
|
||||||
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const depsBlock = source.match(
|
const depsBlock = source.match(
|
||||||
@@ -863,18 +846,3 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
|
|||||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('main process guards internal subtitle extraction with the remote media detector', () => {
|
|
||||||
const source = readMainSource();
|
|
||||||
const resolverWiring = source.match(
|
|
||||||
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
|
|
||||||
)?.groups?.body;
|
|
||||||
|
|
||||||
assert.ok(resolverWiring);
|
|
||||||
assert.match(source, /const detectRemoteMediaPath = createRemoteMediaPathDetector\(\);/);
|
|
||||||
assert.match(resolverWiring, /isRemoteMediaPath:\s*detectRemoteMediaPath/);
|
|
||||||
assert.match(
|
|
||||||
resolverWiring,
|
|
||||||
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
@@ -51,11 +50,10 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 35);
|
assert.equal(calls.length, 34);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
assert.ok(calls.includes('cleanup-internal-subtitles'));
|
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||||
@@ -99,7 +97,6 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
|||||||
calls.push('stop-jellyfin-remote');
|
calls.push('stop-jellyfin-remote');
|
||||||
throw new Error('stop failed');
|
throw new Error('stop failed');
|
||||||
},
|
},
|
||||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
@@ -107,11 +104,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.throws(() => cleanup(), /stop failed/);
|
assert.throws(() => cleanup(), /stop failed/);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
|
||||||
'stop-jellyfin-remote',
|
|
||||||
'cleanup-jellyfin-subtitles',
|
|
||||||
'cleanup-internal-subtitles',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
destroyYomitanSettingsWindow: () => void;
|
destroyYomitanSettingsWindow: () => void;
|
||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
cleanupInternalSubtitleTrackCache: () => void;
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||||
cleanupYoutubeMediaCache: () => void;
|
cleanupYoutubeMediaCache: () => void;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
@@ -68,11 +67,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
try {
|
try {
|
||||||
deps.stopJellyfinRemoteSession();
|
deps.stopJellyfinRemoteSession();
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
deps.cleanupJellyfinSubtitleCache();
|
||||||
deps.cleanupJellyfinSubtitleCache();
|
|
||||||
} finally {
|
|
||||||
deps.cleanupInternalSubtitleTrackCache();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
deps.cleanupYoutubeSubtitleTempDirs();
|
deps.cleanupYoutubeSubtitleTempDirs();
|
||||||
deps.cleanupYoutubeMediaCache();
|
deps.cleanupYoutubeMediaCache();
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
|
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
@@ -96,7 +95,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
assert.ok(calls.includes('destroy-first-run-window'));
|
assert.ok(calls.includes('destroy-first-run-window'));
|
||||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||||
assert.ok(calls.includes('cleanup-internal-subtitles'));
|
|
||||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
@@ -154,7 +152,6 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
cleanupInternalSubtitleTrackCache: () => {},
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
cleanupYoutubeMediaCache: () => {},
|
cleanupYoutubeMediaCache: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => {},
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
@@ -207,7 +204,6 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
cleanupInternalSubtitleTrackCache: () => {},
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
cleanupYoutubeMediaCache: () => {},
|
cleanupYoutubeMediaCache: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => {},
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
|
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
cleanupInternalSubtitleTrackCache: () => void;
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||||
cleanupYoutubeMediaCache: () => void;
|
cleanupYoutubeMediaCache: () => void;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
@@ -145,7 +144,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
},
|
},
|
||||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||||
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||||
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
|
||||||
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -212,69 +211,6 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parsed cues replace a duplicate raw autoplay subtitle that was already primed', async () => {
|
|
||||||
const rawText = 'ジグザグな道を抜け\nジグザグな道を抜け';
|
|
||||||
const correctedText = 'ジグザグな道を抜け';
|
|
||||||
const mediaPath = '/media/video.mkv';
|
|
||||||
let currentSubText = '';
|
|
||||||
const emitted: string[] = [];
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: mediaPath,
|
|
||||||
currentTimePos: 90,
|
|
||||||
currentSubText: rawText,
|
|
||||||
requestProperty: async (name: string) => {
|
|
||||||
if (name === 'sub-text') return rawText;
|
|
||||||
if (name === 'time-pos') return 90;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const cues = parseSubtitleCues(
|
|
||||||
[
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
`Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
|
|
||||||
`Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
|
|
||||||
].join('\n'),
|
|
||||||
'startup-ending.ass',
|
|
||||||
);
|
|
||||||
let activeCues = cues.slice(0, 0);
|
|
||||||
const runtime = createAutoplaySubtitlePrimingRuntime({
|
|
||||||
getCurrentMediaPath: () => mediaPath,
|
|
||||||
getMpvClient: () => client,
|
|
||||||
setCurrentSubText: (text) => {
|
|
||||||
currentSubText = text;
|
|
||||||
},
|
|
||||||
getCurrentSubText: () => currentSubText,
|
|
||||||
getCurrentSubtitleData: () => null,
|
|
||||||
getActiveParsedSubtitleCues: () => activeCues,
|
|
||||||
setActiveParsedSubtitleMediaPath: () => {},
|
|
||||||
subtitleProcessingController: {
|
|
||||||
consumeCachedSubtitle: () => null,
|
|
||||||
onSubtitleChange: () => true,
|
|
||||||
refreshCurrentSubtitle: () => true,
|
|
||||||
notePlainSubtitleEmitted: () => {},
|
|
||||||
},
|
|
||||||
emitSubtitlePayload: (payload) => emitted.push(payload.text),
|
|
||||||
getSubtitlePrefetchService: () => null,
|
|
||||||
getLastObservedTimePos: () => 90,
|
|
||||||
getVisibleOverlayVisible: () => true,
|
|
||||||
emitSecondarySubtitle: () => {},
|
|
||||||
initSubtitlePrefetch: async () => {},
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: async () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
|
||||||
assert.equal(currentSubText, rawText);
|
|
||||||
|
|
||||||
activeCues = cues;
|
|
||||||
await runtime.primeAutoplaySubtitleFromParsedCues(mediaPath, cues);
|
|
||||||
|
|
||||||
assert.equal(currentSubText, correctedText);
|
|
||||||
assert.deepEqual(emitted, [rawText, correctedText]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Driven by the real processing controller rather than a stub: the failure this
|
// Driven by the real processing controller rather than a stub: the failure this
|
||||||
// covers is a disagreement between the priming path and the controller's own
|
// covers is a disagreement between the priming path and the controller's own
|
||||||
// staleness rules, which a hand-written stub cannot reproduce.
|
// staleness rules, which a hand-written stub cannot reproduce.
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
currentVideoPath?: string;
|
currentVideoPath?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
currentSubText?: string;
|
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
};
|
};
|
||||||
@@ -108,19 +107,11 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
autoplaySubtitlePrimedMediaPath = null;
|
autoplaySubtitlePrimedMediaPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitAutoplayPrimedSubtitle(
|
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
||||||
mediaPath: string,
|
|
||||||
text: string,
|
|
||||||
options: { replaceExisting?: boolean } = {},
|
|
||||||
): boolean {
|
|
||||||
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||||
if (!options.replaceExisting || deps.getCurrentSubText() === text) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +252,11 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
cues: SubtitleCue[],
|
cues: SubtitleCue[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) {
|
if (
|
||||||
|
cues.length === 0 ||
|
||||||
|
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
||||||
|
!isCurrentAutoplayMediaPath(mediaPath)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,21 +265,16 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSeconds = Number(
|
const currentTimeSeconds = Number(
|
||||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
);
|
);
|
||||||
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
|
||||||
const cue = selectAutoplayStartupCue(
|
const cue = selectAutoplayStartupCue(
|
||||||
cues,
|
cues,
|
||||||
resolvedTimeSeconds,
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||||
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||||
);
|
);
|
||||||
const liveText = client?.currentSubText ?? '';
|
if (!cue) {
|
||||||
const text = liveText.trim()
|
|
||||||
? resolvePrimarySubtitleText({ liveText, currentTimeSec: resolvedTimeSeconds, cues })
|
|
||||||
: (cue?.text ?? '');
|
|
||||||
if (!text) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true });
|
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: async () => {},
|
stopJellyfinRemoteSession: async () => {},
|
||||||
cleanupInternalSubtitleTrackCache: () => {},
|
|
||||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
cleanupYoutubeMediaCache: () => {},
|
cleanupYoutubeMediaCache: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => {},
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import process from 'node:process';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildFfmpegSubtitleExtractionArgs,
|
buildFfmpegSubtitleExtractionArgs,
|
||||||
createCachedInternalSubtitleTrackExtractor,
|
|
||||||
extractInternalSubtitleTrackToTempFile,
|
extractInternalSubtitleTrackToTempFile,
|
||||||
parseTrackId,
|
parseTrackId,
|
||||||
} from './internal-subtitle-extraction';
|
} from './internal-subtitle-extraction';
|
||||||
@@ -23,65 +22,6 @@ test('parseTrackId rejects negative track ids', () => {
|
|||||||
assert.equal(parseTrackId(' -2 '), null);
|
assert.equal(parseTrackId(' -2 '), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cached internal subtitle extraction shares concurrent and repeated track requests', async () => {
|
|
||||||
let extractionCalls = 0;
|
|
||||||
let cleanupCalls = 0;
|
|
||||||
let resolveExtraction:
|
|
||||||
| ((result: { path: string; cleanup: () => Promise<void> }) => void)
|
|
||||||
| undefined;
|
|
||||||
const firstExtraction = new Promise<{ path: string; cleanup: () => Promise<void> }>((resolve) => {
|
|
||||||
resolveExtraction = resolve;
|
|
||||||
});
|
|
||||||
const extractor = createCachedInternalSubtitleTrackExtractor({
|
|
||||||
extract: async () => {
|
|
||||||
extractionCalls += 1;
|
|
||||||
if (extractionCalls === 1) {
|
|
||||||
return firstExtraction;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
path: `/tmp/subtitle-${extractionCalls}.ass`,
|
|
||||||
cleanup: async () => {
|
|
||||||
cleanupCalls += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const request = () =>
|
|
||||||
extractor.extract('ffmpeg', '/Volumes/media/episode.mkv', {
|
|
||||||
'ff-index': 3,
|
|
||||||
codec: 'ass',
|
|
||||||
});
|
|
||||||
|
|
||||||
const concurrent = Array.from({ length: 6 }, request);
|
|
||||||
assert.equal(extractionCalls, 1);
|
|
||||||
if (!resolveExtraction) {
|
|
||||||
throw new Error('extraction did not start');
|
|
||||||
}
|
|
||||||
resolveExtraction({
|
|
||||||
path: '/tmp/subtitle-1.ass',
|
|
||||||
cleanup: async () => {
|
|
||||||
cleanupCalls += 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(concurrent);
|
|
||||||
assert.deepEqual(
|
|
||||||
results.map((result) => result?.path),
|
|
||||||
Array.from({ length: 6 }, () => '/tmp/subtitle-1.ass'),
|
|
||||||
);
|
|
||||||
await Promise.all(results.map((result) => result?.cleanup()));
|
|
||||||
assert.equal(cleanupCalls, 0);
|
|
||||||
|
|
||||||
assert.equal((await request())?.path, '/tmp/subtitle-1.ass');
|
|
||||||
assert.equal(extractionCalls, 1);
|
|
||||||
|
|
||||||
extractor.clear();
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
assert.equal(cleanupCalls, 1);
|
|
||||||
assert.equal((await request())?.path, '/tmp/subtitle-2.ass');
|
|
||||||
assert.equal(extractionCalls, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
|
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
|
||||||
const videoPath = path.join(root, 'video.mkv');
|
const videoPath = path.join(root, 'video.mkv');
|
||||||
|
|||||||