Compare commits

...

79 Commits

Author SHA1 Message Date
sudacode 6fe1e0fee4 refactor(stats): drop dead IPC handlers, unify stats types over HTTP (#164) 2026-07-13 12:09:56 -07:00
sudacode 0ab840b362 refactor(tokenizer): extract subtitle annotation filter into rule table (#162) 2026-07-12 23:54:13 -07:00
sudacode 21a06c12fb refactor(tokenizer): consolidate kana/POS helpers into token-classification (#161) 2026-07-12 23:27:25 -07:00
sudacode 14070acceb fix(overlay): defer autoplay release for untokenized startup priming
- Autoplay-ready gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback doesn't resume before the first tokenized cue
- Wire isTokenizationReady dep through main.ts and update gate/wiring tests accordingly
- Drop stale release/release-notes.md, add changelog entry under changes/
2026-07-12 02:11:10 -07:00
sudacode 7d81342f0f fix(app): keep AppImage mount alive until Chromium children finish shutdown
Background AppImage launches executed directly from a FUSE mount owned by a
short-lived helper's AppImage runtime. On quit, the runtime unmounted the
squashfs while utility children (network service) were still mid-shutdown,
SIGBUSing them and triggering KDE Service Crash notifications on every video
close. Detach a POSIX-sh supervisor instead: mount via --appimage-mount, run
AppRun from the mount, and release the holder only once no process still
executes from the mount. Falls back to the old direct launch on any failure
or with SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1.
2026-07-12 02:09:42 -07:00
sudacode 4b7f750919 feat(animetosho): add English/Japanese subtitle download integration (#159) 2026-07-12 00:48:12 -07:00
sudacode 6ab3d823a4 feat(shortcuts): make clipboard-video-append shortcut configurable (#158) 2026-07-11 23:36:44 -07:00
sudacode 8797719a09 docs(docs-site): correct and clarify docs against current code
Comprehensive accuracy pass over docs-site verifying every page against
current source. Fixes wrong/stale claims (AniSkip default, YouTube track
selection, Anki sentence-card requirements, immersion schema v18 + SQL
column names, plugin entrypoint, launcher flags, Cloudflare deploy path,
etc.) and fills gaps (watch history, mediaCache.maxHeight, youtubeSubgen,
character-dictionary refresh/eviction, expanded hot-reload lists).
2026-07-11 00:53:34 -07:00
sudacode 8acc78cc1c test(tokenizer): add golden-file regression corpus with 11 fixtures (#157) 2026-07-11 00:50:03 -07:00
sudacode 321461c50f feat(launcher): add subminer sync for cross-machine stats/history merge over SSH (#152) 2026-07-11 00:40:15 -07:00
sudacode beeab564b4 chore(release): prepare v0.18.0 2026-07-10 02:03:10 -07:00
sudacode cab7975a2b chore(docs): remove implemented plan docs
- Drop early-managed-overlay-startup and macos-notification-hover design docs (both implemented, no longer needed)
2026-07-10 01:09:52 -07:00
sudacode 846292809c docs(changes): consolidate and tighten changelog entries
- Merge related fix entries into single files (name-match-span-merge into character-name-split-and-scan-recovery, kanji-non-independent-noun-annotations + lexicalized-expression-frequency into content-adverb-annotation-stop-list, greedy-name-parsing-priority into character-name-split-and-scan-recovery, kiku-field-grouping-cancel-double-toast into kiku-field-grouping-modal-recovery, history-rofi-cover-art into launcher-history-command)
- Rewrite remaining entries for brevity and reclassify stats-trend-title-limits as "changed"
2026-07-10 01:04:01 -07:00
sudacode 8712780d08 fix(tokenizer): keep frequency rank for lexicalized kana expressions (#156) 2026-07-10 00:54:17 -07:00
sudacode 7b1a005a65 feat(anki): mirror mpv software volume into generated sentence audio (#155) 2026-07-10 00:44:23 -07:00
sudacode 84c75f50aa fix(release): skip gh attribution without CI token 2026-07-09 02:26:00 -07:00
sudacode 8b838f2c7d chore(release): prepare 0.18.0-beta.3 2026-07-09 01:20:02 -07:00
sudacode db4139ba0b fix(tokenizer): block kanji reading collisions and name/generic length t
- Add allowReadingOnlyMatch:false to kanji token known-word lookups so 渓谷/けいこく no longer matches a mined 警告/けいこく card via reading-only index
- Greedy name pre-pass yields when a strictly longer generic word starts at the same position (空 no longer splits 空気; ties still go to the name)
2026-07-09 01:03:58 -07:00
sudacode 6c251502b3 fix(tokenizer): exclude unparsed-run tokens from annotations and N+1 (#153) 2026-07-09 00:28:38 -07:00
sudacode ae40934d3a fix(tokenizer): greedy name pre-pass to prevent generic matches swallowing character names (#151) 2026-07-08 23:53:45 -07:00
sudacode cdb1475a54 chore(assets): update icons and favicons 2026-07-08 22:35:47 -07:00
sudacode a2e49b369b fix(tokenizer): merge scanner metadata per token instead of all-or-nothi
- Replace hasSameTokenSpans + full-discard with mergeScannerTokensIntoParseTokens
- Grafts isNameMatch/frequencyRank/etc onto matching parse spans; filler chunks degrade only themselves
- Fixes name annotations dropping for entire subtitle lines containing unmatched interjections
2026-07-08 22:26:34 -07:00
sudacode 7f13aed50a fix(overlay): keep frequency/JLPT highlight for kanji non-independent nouns (#150) 2026-07-08 22:25:32 -07:00
sudacode 8b21a2bca8 fix(overlay): remove content adverbs from annotation stop-word list
- 確かに and やはり no longer excluded from frequency/JLPT highlighting and vocab stats
- Stop-word list now covers only interjections, pronouns, and grammar fragments
2026-07-08 20:15:45 -07:00
sudacode 925413adfe chore(release): prepare 0.18.0-beta.2 2026-07-08 02:31:14 -07:00
sudacode d0644ab2eb fix(stats): parse v3 reading-aware known-word cache in stats server (#149) 2026-07-08 02:15:58 -07:00
sudacode d253710c2e fix(stats): fetch cover art eagerly at session start instead of on series page visit (#148) 2026-07-08 00:59:35 -07:00
sudacode c3df510e4f docs(release): reclassify audio normalization as added, not fixed
- Move card audio normalization entry from Fixed → Added in prerelease notes
- Update changes/audio-normalization.md type: fixed → added
2026-07-08 00:38:38 -07:00
sudacode 187f68e5b6 fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings (#147) 2026-07-07 23:57:47 -07:00
sudacode 0e254cbbef fix(launcher): move fzf previews below menus 2026-07-07 22:36:56 -07:00
sudacode 7b94adafbd chore(release): prepare 0.18.0-beta.1 2026-07-07 02:40:05 -07:00
sudacode 61f39d1e09 fix(overlay): resolve unspaced Japanese name splits and scan recovery (#146) 2026-07-07 02:33:46 -07:00
sudacode e7739de51c fix(tokenizer): keep unparsed Yomitan tokens hoverable (#145) 2026-07-07 02:01:04 -07:00
sudacode ad1d240f20 refactor(tests): centralize lane definitions and add per-file isolation (#143) 2026-07-07 01:51:05 -07:00
sudacode 8b0ef662bc chore: remove shipped changelog entries and demo asset
- Drop changes/kiku-field-grouping-modal-wiring.md (landed in #138)
- Drop changes/known-word-partial-reading-highlight.md (landed in #142)
- Remove assets/minecard.mkv demo video
2026-07-07 00:18:48 -07:00
sudacode 0a58c20ad7 fix(stats): start stats server on background app launch (#144) 2026-07-07 00:16:57 -07:00
sudacode 38ddb29aa0 feat(anki): reading-aware known-word matching (cache v3) (#142) 2026-07-07 00:13:10 -07:00
sudacode 8b9a70c5a6 feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140) 2026-07-06 23:52:43 -07:00
sudacode 48a084914a feat(branding): replace app icon with contributed pixel-art set (#141) 2026-07-06 23:31:19 -07:00
sudacode a042b04357 fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138) 2026-07-06 22:13:14 -07:00
sudacode 35ca2afc6f fix(overlay): collapse karaoke syllable spam in secondary subtitles (#139) 2026-07-06 01:09:06 -07:00
sudacode b14f977e33 fix(tokenizer): stop partial furigana readings from marking words known (#137) 2026-07-05 17:02:44 -07:00
sudacode eef4500599 feat(launcher): add -H/--history command to browse local watch history (#136) 2026-07-05 16:46:05 -07:00
sudacode 73af1451b7 fix(anki): lower audio normalization target to -23 LUFS (EBU R128) 2026-07-05 01:13:54 -07:00
sudacode 36a3704815 fix(youtube): parse mpv EDL stream URLs with byte-length guards (#134) 2026-07-02 01:37:37 -07:00
sudacode 359cb0a301 Normalize generated Anki audio by default (#135) 2026-07-02 00:34:16 -07:00
sudacode 4b10e85053 chore(release): prepare v0.17.2 2026-06-28 23:03:18 -07:00
sudacode c942a2cf2d fFix(youtube): recover source URL for background media cache on direct mpv open (#132) 2026-06-28 22:46:11 -07:00
sudacode f65afa6046 feat(youtube): notify on manual picker open and show success after track load (#133) 2026-06-28 22:43:16 -07:00
sudacode 389d8e06e0 chore: remove stale npm artifacts 2026-06-28 20:18:08 -07:00
sudacode 0008b55b70 chore(release): prepare v0.17.1 2026-06-27 22:59:28 -07:00
sudacode d16ae9c745 chore(release): prepare v0.17.1-beta.1 2026-06-27 22:10:02 -07:00
sudacode 36f94151b8 fix(logs): use local date for log filenames and expand export redaction (#131) 2026-06-27 21:08:11 -07:00
sudacode 5326ad32f5 feat(youtube): add mediaCache mode and safer stream media extraction (#130) 2026-06-27 19:20:18 -07:00
sudacode d199376364 chore(release): prepare v0.17.0 2026-06-15 01:58:42 -07:00
sudacode 3aee88c150 chore(release): prepare v0.17.0-beta.2 2026-06-14 20:58:26 -07:00
sudacode 70da3ee8bd Fix Windows Anki startup and overlay regressions (#128) 2026-06-14 20:51:56 -07:00
sudacode aa8eb753f6 fix: scope prerelease note reuse by version 2026-06-14 18:04:03 -07:00
sudacode 8d73de8731 docs: scope 0.17.0 beta prerelease notes 2026-06-14 17:50:09 -07:00
sudacode 5a98397efe chore: prepare 0.17.0-beta.1 prerelease 2026-06-14 17:34:05 -07:00
sudacode a117c5759c fix(linux): auto-install managed plugin copy; include in asset updates (#127) 2026-06-14 17:25:28 -07:00
sudacode ae7e6f82a8 fix(overlay): restore macOS Yomitan popup focus without breaking click-away (#125) 2026-06-14 16:46:13 -07:00
sudacode 1158be5b39 fix(overlay): preserve visible state across playlist item transitions (#124) 2026-06-12 23:38:54 -07:00
sudacode 33e767458f refactor(main): split main.ts into focused runtime modules (#123) 2026-06-12 17:35:46 -07:00
sudacode 94a65416ae fix(stats): strip Season N suffix from AniList title searches (#121) 2026-06-12 01:07:11 -07:00
sudacode 0a384a22c9 Replace subtitle delay actions with native mpv keybindings (#120) 2026-06-12 00:03:06 -07:00
sudacode b3b45521b6 fix(release): preserve attribution placement; default update notifs to o
- Move What's Changed/New Contributors before Installation in release notes
- Preserve committed attribution when regenerating via writeReleaseNotesForVersion
- Change notificationType default from 'both' to 'overlay' for new installs
2026-06-10 23:53:31 -07:00
sudacode 131b23efa9 chore(release): prepare v0.16.0 2026-06-10 23:05:37 -07:00
sudacode e2afceb492 fix(anki): write sentence card audio only to sentence audio field (#118) 2026-06-10 22:51:19 -07:00
sudacode 7be1843c41 feat(notifications): add overlay notifications with position config (#110) 2026-06-10 22:46:52 -07:00
sudacode c09d009a3e docs: add ani-skip to credits table in README 2026-06-10 00:01:51 -07:00
sudacode 2007e28be8 feat(aniskip): move intro detection from mpv plugin to app runtime (#117) 2026-06-09 23:55:43 -07:00
sudacode d5bfdcae7b fix(stats): repair legacy combined-season anime rows on startup (#116) 2026-06-09 12:41:07 -07:00
sudacode 311f1e8ee5 feat(stats): speed up session maintenance and improve stats UI (#111) 2026-06-08 02:20:52 -07:00
sudacode e6a16a069b fix(anilist): mark entry completed when final episode is reached (#115) 2026-06-07 23:45:09 -07:00
Autumn (Bee) af67c53dd6 [codex] Restart Jellyfin remote session after setup login (#112) 2026-06-06 11:52:16 -07:00
sudacode ea79e331fa build: make deps initializes submodules before installing JS deps
- Add `submodules` target that runs `git submodule update --init --recursive`
- `deps` now depends on `submodules` so fresh checkouts work out of the box
- Update docs to replace manual install steps with `make deps`
- Fix Windows build-from-source steps to include stats and submodule init
2026-06-06 01:25:01 -07:00
sudacode ee89b0c8a9 feat(release): add contributor attribution to release notes (#114) 2026-06-06 01:07:47 -07:00
sudacode f2fd58cd2b docs(changelog): require reconciled fragments, not just new ones (#113) 2026-06-06 00:55:34 -07:00
601 changed files with 166046 additions and 12344 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
name: Bug Report
description: Report something that is broken or behaving incorrectly
title: "[Bug]: "
labels: ["bug"]
title: '[Bug]: '
labels: ['bug']
body:
- type: markdown
attributes:
+2 -2
View File
@@ -1,7 +1,7 @@
name: Feature Request
description: Suggest a new feature or an improvement to an existing one
title: "[Feature]: "
labels: ["enhancement"]
title: '[Feature]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
+1 -1
View File
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
## Checklist
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
- [ ] Reconciled current-outcome changelog fragment(s), or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
- [ ] Relevant checks pass locally (typecheck, tests, build)
+3 -3
View File
@@ -55,9 +55,6 @@ jobs:
- name: Verify generated config examples
run: bun run verify:config-example
- name: Internal docs knowledge-base checks
run: bun run test:docs:kb
- name: Test suite (source)
run: bun run test:fast
@@ -71,6 +68,9 @@ jobs:
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
+3
View File
@@ -70,6 +70,9 @@ jobs:
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
+3
View File
@@ -61,6 +61,9 @@ jobs:
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
+3
View File
@@ -1,5 +1,6 @@
# Dependencies
node_modules/
/package-lock.json
# Superpowers brainstorming
.superpowers/
@@ -16,6 +17,8 @@ coverage/
# Launcher build artifact (produced by make build-launcher)
/subminer
/main-entry.js
/main-entry.js.map
# Logs
*.log
+1
View File
@@ -4,3 +4,4 @@ release
coverage
vendor
*.log
src/core/services/tokenizer/__fixtures__/golden/*.json
+4 -1
View File
@@ -42,7 +42,10 @@ Start here, then leave this file.
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Stats dashboard UI (`stats/`): `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Docs-only: `bun run docs:test`, then `bun run docs:build`
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep
@@ -68,7 +71,7 @@ Start here, then leave this file.
## Release / PR Notes
- User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out)
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
- User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers:
+129
View File
@@ -1,5 +1,134 @@
# Changelog
## v0.18.0 (2026-07-10)
### Added
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
### Changed
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
- App Icon: Replaced the app icon with new pixel-art submarine artwork contributed by the community, used across the app icon, tray, notifications, README, docs site, and Stats page.
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
### Fixed
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
- Cover Art Fetching: Stats now fetches AniList cover art as soon as a new series starts playing, and backfills missing art for existing series on the next Stats visit.
- Kiku Field Grouping: The manual field-grouping dialog now stays above fullscreen mpv, remains usable across repeated attempts, closes abandoned windows after timeouts, and reports a clear error when the original card can no longer be loaded.
- Karaoke Subtitle Collapse: Secondary subtitles no longer stack dozens of one-syllable lines during openings and endings; repeated events collapse into one line, capped to a strip at the top.
- Unparsed Token Hover: Subtitle text Yomitan can't parse (truncated inflections, elongation runs) remains hoverable, while staying excluded from highlighting, N+1 candidate math, and vocabulary stats.
- YouTube Streaming: Fixed direct YouTube stream extraction that could corrupt signed URLs and cause ffmpeg 403 errors.
<details>
<summary>Internal changes</summary>
### Internal
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
</details>
## v0.17.2 (2026-06-28)
### Fixed
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
## v0.17.1 (2026-06-27)
### Added
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
### Fixed
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
## v0.17.0 (2026-06-15)
### Changed
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
### Fixed
- **Anki Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
- **Anki Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
- **Anki Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
- **Character Dictionary Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
- **Overlay macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
- **Overlay Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
- **Overlay Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
- **Overlay Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
- **Stats AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
- **Updates Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
### Docs
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
<details>
<summary>Internal changes</summary>
### Internal
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
</details>
## v0.16.0 (2026-06-10)
### Breaking Changes
- **Notification Mode `both`**: `notificationType: "both"` now routes to overlay + system notifications. Users who previously used `"both"` for mpv OSD + system notifications should set `notificationType` to `"osd-system"` in `config.jsonc`. The `osd` and `osd-system` values remain valid as config-file entries but no longer appear in Settings.
### Added
- **Overlay Notifications**: New overlay notification stack (Catppuccin Macchiato theme) with configurable screen position (`notifications.overlayPosition`: top-left, top-center, top-right), 3-second transient dismissals, and persistent cards for long-running jobs like character dictionary sync.
- **Notification History Panel**: `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`) opens a session log of all notifications. Works whether the overlay or mpv has focus; slides in from the notification edge; entries can be individually removed or cleared.
- **Mined-Card Notification Actions**: Mined-card overlay notifications show generated card thumbnails and include an Open in Anki button in both live cards and history entries.
- **Update Notification Action**: Update-available overlay notifications include an Update button to start the app update flow directly from the notification.
- **Stats Search**: New Search tab in Stats for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
### Changed
- **AniSkip**: Moved intro detection from the mpv plugin to the SubMiner app. Lookups now cover every file loaded during a session including playlist advances, and `mpv.aniskipEnabled`/`mpv.aniskipButtonKey` hot-reload without restarting playback. The bundled plugin no longer makes network calls. Note: AniSkip now requires the SubMiner app to be connected; plugin-only mpv sessions will not fetch skip windows.
- **Stats Library**: Entries are now split by detected season (season folder first, filename parsing as fallback). Existing combined-series rows are automatically migrated to per-season entries on startup. Cover art and anime details refresh immediately after a manual AniList entry change.
- **Stats Vocabulary**: Remembers Hide Known/Hide Kana filters across sessions, applies Hide Kana filtering cross-title, collapses duplicate token variants in exclusions, and matches Related Seen Words by shared readings or kanji.
- **Stats Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
- **Stats Mining**: Sentence cards are created before slow media generation finishes; stored/requested secondary subtitles are preserved before falling back to sidecar or alass-retimed English subtitles; empty `ankiConnect.deck` falls back to Yomitan's mining deck; partial media failures are surfaced.
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
### Fixed
- **AniList Completion**: Entries are now marked completed when a post-watch update reaches the final known episode of the season.
- **AniSkip Markers**: Fixed intro markers disappearing after same-media mpv reloads; fixed metadata detection for intros that start at 0 seconds and common release-group filenames.
- **Jellyfin Session**: Remote session now restarts after setup login so the websocket reconnects with fresh credentials, and stops cleanly on logout.
- **Sentence Card Audio**: Mining a sentence card no longer fills the expression audio field; generated audio goes only to the configured sentence audio field.
- **Stats Mining Fields**: Sentence clips update `SentenceAudio` correctly; word audio uses configured Yomitan sources; English subtitle text is not written to word cards; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- **Overlay Hover Readiness**: Subtitle bars are hoverable and clickable from the first subtitle line on visible overlay startup or resume, without waiting for the next subtitle event.
- **Startup Autoplay**: Playback is released after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
- **Overlay Startup Feedback**: Restored mpv OSD loading spinner that starts on connect, media open, or overlay request, and clears once the overlay is content-ready and visible.
- **Linux Overlay Input**: Notification close and action buttons remain clickable above subtitle bars on Linux.
<details>
<summary>Internal changes</summary>
### Internal
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
</details>
## v0.15.2 (2026-06-02)
### Changed
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+7 -4
View File
@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
.PHONY: help submodules deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi
@@ -72,7 +72,8 @@ help:
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
"" \
"Other targets:" \
" deps Install JS dependencies (root + stats + texthooker-ui)" \
" submodules Initialize/update git submodules" \
" deps Initialize submodules and install JS dependencies (root + stats + texthooker-ui)" \
" uninstall-linux Remove Linux install artifacts" \
" uninstall-macos Remove macOS install artifacts" \
" uninstall-windows Remove Windows mpv plugin artifacts" \
@@ -105,8 +106,10 @@ print-dirs:
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
deps:
@$(MAKE) --no-print-directory ensure-bun
submodules:
@git submodule update --init --recursive
deps: submodules ensure-bun
@bun install
@cd stats && bun install --frozen-lockfile
@cd vendor/texthooker-ui && bun install --frozen-lockfile
+3
View File
@@ -121,6 +121,7 @@ Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
<details>
<summary><b>Platform-specific install commands</b></summary>
@@ -212,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse local watch history (replay / next episode / browse)
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
@@ -228,6 +230,7 @@ SubMiner builds on the work of these open-source projects:
| Project | Role |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.
+7
View File
@@ -31,6 +31,13 @@ Rules:
- `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
PR branch workflow:
- Before adding a fragment or bullet, inspect the `changes/*.md` files already changed in the PR
- If the new work fixes, modifies, renames, or supersedes behavior introduced or referenced by that fragment, edit or remove the stale bullet instead of adding follow-up churn
- Add a new bullet only when it describes a truly separate user-visible outcome
- Multiple fragment files are allowed when one PR has genuinely separate release-note outcomes, but keep them minimized and current
How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- Added an Animetosho integration for downloading English (and Japanese) subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openAnimetosho`) opens an in-overlay modal with two language tabs (the first follows `secondarySub.secondarySubLanguages`, defaulting to English; the second is Japanese) that parses the current video filename, searches Animetosho releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads it into mpv immediately - Japanese tracks as the primary subtitle, other languages as the secondary subtitle. No API key is required; also reachable via `subminer --open-animetosho`, the `__animetosho-open` keybinding command, and configurable under a new `animetosho` config section.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
@@ -0,0 +1,4 @@
type: changed
area: shortcuts
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
@@ -0,0 +1,4 @@
type: fixed
area: app
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
+7
View File
@@ -0,0 +1,7 @@
type: internal
area: tokenizer
- Added a golden-file regression corpus for the tokenizer/annotation pipeline: recorded Yomitan backend responses and MeCab tokens replay through the real tokenizeSubtitle pipeline in bun tests without Electron or dictionaries.
- Added `record-tokenizer-fixture:electron` script to capture new fixtures from a live Yomitan/MeCab session, with flags for known words, JLPT levels, and annotation toggles.
- Seeded eleven fixtures covering the #147#156 regression classes (grammar-helper suppression, lexical くれる, kanji non-independent nouns, N+1 targeting, reading collisions, unparsed runs, ordinal/honorific prefixes).
- Added `compare-yomitan-api:electron` script that diffs SubMiner tokenization against a stock Yomitan instance via the yomitan-api bridge (segmentation, readings, headword forms).
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH, with `--push` and `--pull` for one-way insert-only transfers. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or a live mpv session is active (`--force` overrides), ignores stale mpv socket files, keeps the guard in place through local/remote merges, supplies standard SubMiner and Bun paths to non-interactive SSH commands, verifies the remote launcher starts, reports remote stderr on failures, and aborts on stats schema version mismatches.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: stats
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
+67 -13
View File
@@ -172,10 +172,19 @@
"updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior.
// ==========================================
// Notifications
// Overlay notification display behavior.
// Hot-reload: position changes apply to the next overlay notification.
// ==========================================
"notifications": {
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -196,10 +205,13 @@
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openAnimetosho": "Ctrl+Shift+T", // Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
"appendClipboardVideoToQueue": "CommandOrControl+A" // Accelerator that appends a video path from the clipboard to the mpv playlist.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -280,15 +292,41 @@
] // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
"command": [
"__sub-delay-next-line"
"sub-step",
-1
] // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"key": "Ctrl+Shift+ArrowRight", // Key setting.
"command": [
"__sub-delay-prev-line"
"sub-step",
1
] // Command setting.
},
{
"key": "KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
-0.1
] // Command setting.
},
{
"key": "Shift+KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
"key": "KeyX", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
@@ -479,7 +517,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -496,7 +534,7 @@
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
@@ -523,6 +561,8 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
@@ -532,14 +572,14 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
@@ -573,6 +613,16 @@
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
// Animetosho
// Animetosho subtitle search configuration (English and Japanese). No API key required.
// Hot-reload: Animetosho changes apply to the next Animetosho request.
// ==========================================
"animetosho": {
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required.
"maxSearchResults": 10 // Maximum Animetosho search results returned.
}, // Animetosho subtitle search configuration (English and Japanese). No API key required.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
@@ -582,7 +632,11 @@
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"mediaCache": {
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
} // Media cache setting.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ==========================================
@@ -634,8 +688,8 @@
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
}, // SubMiner-managed mpv launch and bundled plugin options.
// ==========================================
+2
View File
@@ -327,7 +327,9 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' },
{ text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'Animetosho', link: '/animetosho-integration' },
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
],
},
+5 -4
View File
@@ -38,10 +38,10 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
## Update Queue and Retry
@@ -81,7 +81,6 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
"enabled": true,
"accessToken": "",
"characterDictionary": {
"enabled": false,
"maxLoaded": 3,
"profileScope": "all",
"collapsibleSections": {
@@ -99,10 +98,12 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. Character dictionary sync follows `subtitleStyle.nameMatchEnabled`.
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
## CLI Commands
+73
View File
@@ -0,0 +1,73 @@
# Animetosho Integration
[Animetosho](https://animetosho.org) mirrors anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the Animetosho JSON feed API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
This is the English-side companion to the [Jimaku integration](/jimaku-integration): Jimaku covers Japanese subtitles, Animetosho covers your secondary language (English by default). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
::: tip No API key required
Unlike Jimaku, Animetosho needs no account or API key. The only requirement is the `xz` binary on your `PATH` - Animetosho serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
:::
## How It Works
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by language: the first tab follows your `secondarySub.secondarySubLanguages` config (defaults to English when unset, and the tab is labeled accordingly - e.g. "German" if you configure `["de"]`), and the second tab is always **Japanese**. Tracks with no language tag stay visible on the first tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
From there:
1. **Search** - SubMiner queries the Animetosho feed with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), with size and file count.
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from Animetosho's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as the **primary** subtitle; any other language loads as the **secondary** subtitle, so your Japanese primary track stays in place. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
Because releases on Animetosho are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
### Modal Keyboard Shortcuts
| Key | Action |
| ------------------------- | ------------------------------- |
| `Enter` (in text field) | Search |
| `Enter` (in list) | Select release / download track |
| `Arrow Up` / `Arrow Down` | Navigate releases or tracks |
| `Arrow Left` / `Arrow Right` | Switch English / Japanese tab |
| `Escape` | Close modal |
## Configuration
The integration works out of the box. An optional `animetosho` section in `config.jsonc` tunes it:
```jsonc
{
"animetosho": {
"apiBaseUrl": "https://feed.animetosho.org",
"maxSearchResults": 10,
},
}
```
| Option | Type | Default | Description |
| ----------------------------- | -------- | ------------------------------- | ----------------------------------------------------------------------------- |
| `animetosho.apiBaseUrl` | `string` | `"https://feed.animetosho.org"` | Base URL of the Animetosho JSON feed API. Only change this if using a mirror. |
| `animetosho.maxSearchResults` | `number` | `10` | Maximum number of releases returned per search. |
The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
"openAnimetosho": "Ctrl+Shift+T", // default; set to null to disable
},
}
```
## Other Ways to Open It
- CLI: `subminer --open-animetosho`
- Keybinding command: bind any key to `["__animetosho-open"]` in the `keybindings` array
## Troubleshooting
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
- **"Batch releases are not supported"** - the Animetosho feed API only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
+53
View File
@@ -0,0 +1,53 @@
# AniSkip Integration
SubMiner integrates with [AniSkip](https://aniskip.com) to automatically detect anime intro intervals and let you skip them with a single key press.
Intro detection runs in the SubMiner app over the mpv IPC socket. It is available whenever the overlay is connected to mpv - not just at launch - and covers every local file loaded during an mpv session, including playlist advances.
## Setup
AniSkip is enabled by default. Disable it or change the skip key in your config:
```jsonc
{
"mpv": {
"aniskipEnabled": true, // default: true
"aniskipButtonKey": "TAB",
},
}
```
Both settings hot-reload: changing them in your config takes effect immediately without restarting playback or mpv.
For best title and episode detection, install [`guessit`](https://github.com/guessit-io/guessit):
```bash
python3 -m pip install --user guessit
```
Without `guessit`, SubMiner falls back to an internal filename parser which handles most common naming conventions but may miss unusual formats.
## How It Works
On each local file load:
1. SubMiner infers the anime title, season, and episode number from the filename and path (using `guessit` if available, otherwise the built-in parser). Remote URLs are skipped entirely.
2. The title is matched against MyAnimeList to resolve a MAL id.
3. SubMiner queries the AniSkip API for an OP skip interval for that MAL id and episode.
4. If an interval is found, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapter markers to the current file and binds the skip key (`mpv.aniskipButtonKey`, default `TAB`).
5. At the start of the intro, an OSD prompt appears for 3 seconds: `You can skip by pressing TAB` (reflects your configured key). Pressing the key at any point during the intro seeks to the intro end.
When a custom key (other than `TAB` or `y-k`) is configured, the legacy `y-k` chord is also bound as a fallback skip trigger.
Results are cached per file for the app session; only definitive "no intro found" results are cached, so transient lookup failures are retried on the next file load. Reload detection is also handled: if mpv reloads the same file, SubMiner re-applies the chapter markers without a new API lookup.
## Triggering from mpv
You can trigger AniSkip actions from mpv script-messages:
| Command | Effect |
| ------- | ------ |
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
These are handled by the SubMiner app over the IPC socket.
+57 -34
View File
@@ -4,11 +4,12 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
::: tip New to these terms?
- **Anki** is the flashcard app where your study cards live.
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
:::
:::
## Prerequisites
@@ -22,7 +23,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
**Proxy mode** (default) - SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
@@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same:
4. Fills the translation field from the secondary subtitle or AI.
5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks (`added:1`). In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect; stats-dashboard mining also falls back to Yomitan's mining deck when `ankiConnect.deck` is empty.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker)
@@ -55,12 +56,12 @@ Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **default Yomitan profile** (`profiles[0].options.anki.server`) to the active SubMiner endpoint:
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **currently active Yomitan profile**'s Anki server to the active SubMiner endpoint (falling back to `profiles[0]` if the active-profile index is invalid):
- proxy URL when `ankiConnect.proxy.enabled` is `true`
- direct `ankiConnect.url` when proxy mode is disabled
To avoid clobbering custom setups, this auto-update only changes the default profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
To avoid clobbering custom setups, this auto-update only changes the profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
@@ -80,7 +81,7 @@ In Yomitan, go to Settings → Profile and:
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
4. Save and make that profile active when using SubMiner.
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets `profiles[0]` when it is blank or still default.
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets the active profile when its server is blank or still default.
### Proxy Troubleshooting (quick checks)
@@ -100,10 +101,11 @@ curl -sS http://127.0.0.1:8766 \
-d '{"action":"version","version":2}'
```
3. Check both log sinks:
3. Check the log sinks in `~/.config/SubMiner/logs/`:
- Launcher/mpv-integrated log: `~/.cache/SubMiner/mp.log`
- App runtime log: `~/.config/SubMiner/logs/SubMiner-YYYY-MM-DD.log`
- App runtime log: `app-YYYY-MM-DD.log`
- Launcher log: `launcher-YYYY-MM-DD.log`
- mpv log: `mpv-YYYY-MM-DD.log`
4. Ensure config JSONC is valid and logging shape is correct:
@@ -132,7 +134,9 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
}
```
Field names must match your Anki note type exactly (case-sensitive). If a configured field does not exist on the note type, SubMiner skips it without error.
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
### Minimal Config
@@ -160,13 +164,17 @@ Audio is extracted from the video file using the subtitle's start and end timest
"ankiConnect": {
"media": {
"generateAudio": true,
"normalizeAudio": true, // normalize generated clip loudness
"mirrorMpvVolume": true, // apply the current mpv volume level
"audioPadding": 0, // optional seconds before and after subtitle timing
"maxMediaDuration": 30 // cap total duration in seconds
}
}
```
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized to -23 LUFS by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. When subtitle timing is missing, clips fall back to `media.fallbackDuration` seconds (default `3`). Changing these settings applies to the next extraction without restarting SubMiner.
`mirrorMpvVolume` is also enabled by default. Immediately before extracting each playback-overlay card's audio, SubMiner reads mpv's numeric `volume` and applies mpv's cubic software-volume curve after loudness normalization. For example, mpv volume `50` produces `0.5³ = 0.125` gain. Amplified output above mpv volume `100` is limited to a `-1 dBFS` ceiling before MP3 encoding to prevent clipping. It ignores mpv's separate `mute` state. If the volume property is missing, invalid, or unavailable, extraction continues with unity scaling; disabling this option skips the query and volume filter. Changing this setting applies to the next extraction without restarting SubMiner. YouTube cards queued for a background media-cache download retain the volume captured when the card was mined. Stats-dashboard mining does not currently have access to the active mpv property client, so it does not apply mpv volume scaling.
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
@@ -181,8 +189,8 @@ A single frame is captured at the current playback position.
"imageType": "static",
"imageFormat": "jpg", // "jpg", "png", or "webp"
"imageQuality": 92, // 1100
"imageMaxWidth": null, // optional, preserves aspect ratio
"imageMaxHeight": null
"imageMaxWidth": 0, // 0 = preserve source resolution
"imageMaxHeight": 0
}
}
```
@@ -198,13 +206,13 @@ Instead of a static screenshot, SubMiner can generate an animated AVIF covering
"imageType": "avif",
"animatedFps": 10,
"animatedMaxWidth": 640,
"animatedMaxHeight": null,
"animatedMaxHeight": 0, // 0 = preserve aspect ratio
"animatedCrf": 35 // 063, lower = better quality
}
}
```
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds.
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio.
### Behavior Options
@@ -215,11 +223,16 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
"overwriteImage": true, // replace existing image, or append
"mediaInsertMode": "append", // "append" or "prepend" to field content
"autoUpdateNewCards": true, // auto-update when new card detected
"notificationType": "osd" // "osd", "system", "both", or "none"
"highlightWord": true, // bold the mined word inside the sentence field
"notificationType": "overlay" // "overlay", "system", "both", or "none"
}
}
```
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
## AI Translation
@@ -260,14 +273,14 @@ The built-in translation request asks for English output by default. Customize t
SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types.
::: warning Required config
Sentence card creation and audio card marking both require `ankiConnect.isLapis.enabled: true` and a valid `sentenceCardModel` pointing to your Lapis/Kiku note type. Without this, the `Ctrl/Cmd+S` and `Ctrl/Cmd+Shift+A` shortcuts will not create cards.
Sentence card creation and audio card marking require a non-empty `ankiConnect.isLapis.sentenceCardModel` naming a note type that exists in Anki (default: `"Lapis"`). If the model is empty or missing, the `Ctrl/Cmd+S` and `Ctrl/Cmd+Shift+A` shortcuts will not create cards.
:::
```jsonc
"ankiConnect": {
"isLapis": {
"enabled": true,
"sentenceCardModel": "Japanese sentences"
"sentenceCardModel": "Lapis" // default; point at your Lapis/Kiku note type
}
}
```
@@ -294,25 +307,28 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved, and exact duplicate values are collapsed to one entry. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both cards' sentences, audio clips, and images are preserved as grouped entries. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
### What Gets Merged
| Field | Merge behavior |
| -------- | --------------------------------------------------------------- |
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
| Image | Both images kept (exact duplicates deduplicated) |
| Field | Merge behavior |
| -------- | ---------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
### Keyboard Shortcuts in the Modal
| Key | Action |
| --------- | ---------------------------------- |
| `1` / `2` | Select card 1 or card 2 to keep |
| `Enter` | Confirm selection |
| `Esc` | Cancel (keep both cards unchanged) |
| Key | Action |
| ----------- | ---------------------------------- |
| `1` / `2` | Select card 1 or card 2 to keep |
| `Enter` | Confirm selection |
| `Backspace` | Go back from the merge preview |
| `Esc` | Cancel (keep both cards unchanged) |
## Full Config Example
@@ -322,8 +338,10 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"enabled": true,
"url": "http://127.0.0.1:8765",
"pollingRate": 3000,
"deck": "",
"tags": ["SubMiner"],
"proxy": {
"enabled": false,
"enabled": true, // default
"host": "127.0.0.1",
"port": 8766,
"upstreamUrl": "http://127.0.0.1:8765",
@@ -342,6 +360,8 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"imageType": "static",
"imageFormat": "jpg",
"imageQuality": 92,
"normalizeAudio": true,
"mirrorMpvVolume": true,
"audioPadding": 0,
"maxMediaDuration": 30,
},
@@ -350,12 +370,15 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"overwriteImage": true,
"mediaInsertMode": "append",
"autoUpdateNewCards": true,
"notificationType": "osd",
"notificationType": "overlay",
},
"metadata": {
"pattern": "[SubMiner] %f (%t)",
},
"ai": {
"enabled": false,
"model": "openai/gpt-4o-mini",
"systemPrompt": "Translate mined sentence text only.",
"model": "", // e.g. "openai/gpt-4o-mini"
"systemPrompt": "",
},
"isKiku": {
"enabled": false,
@@ -364,7 +387,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
},
"isLapis": {
"enabled": false,
"sentenceCardModel": "Japanese sentences",
"sentenceCardModel": "Lapis",
},
},
"ai": {
+23 -14
View File
@@ -6,7 +6,7 @@ SubMiner is split into three cooperating runtimes:
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
- Launcher CLI (`launcher/`) for mpv/app command workflows.
- mpv Lua plugin (`plugin/subminer/init.lua` + module files) for player-side controls and IPC handoff.
- mpv Lua plugin (`plugin/subminer/main.lua` + module files) for player-side controls and IPC handoff.
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
@@ -24,13 +24,14 @@ Within the desktop app, `src/main.ts` is a composition root that wires small run
```text
launcher/ # Standalone CLI launcher wrapper and mpv helpers
commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough)
commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough/
# dictionary/history/logs/stats/update)
config/ # Launcher config parsers + CLI parser builder
main.ts # Launcher entrypoint and command dispatch
plugin/
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
subminer/ # Modular mpv plugin (main · init · bootstrap · lifecycle · process
# state · messages · hover · ui · options · environment · log
# binary · aniskip · aniskip_match)
# binary · session_bindings · version)
src/
ai/ # AI translation provider utilities (client, config)
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
@@ -38,6 +39,7 @@ src/
preload.ts # Electron preload bridge
types.ts # Shared type definitions
main/ # Main-process composition/runtime adapters
boot/ # Pre-ready boot helpers
app-lifecycle.ts # App lifecycle + app-ready runtime runner factories
character-dictionary-runtime.ts # Character-dictionary orchestration/public runtime API
cli-runtime.ts # CLI command runtime service adapters
@@ -71,17 +73,17 @@ src/
resolve/ # Domain-specific config resolution pipeline stages
shared/ipc/ # Cross-process IPC channel constants + payload validators
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse interaction modules
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
handlers/ # Keyboard/mouse/gamepad interaction modules
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
# character dictionary, playlist browser, subtitle sidebar,
# YouTube track picker, controller config/debug/select)
positioning/ # Subtitle position controller (drag-to-reposition)
settings/ # Settings window UI (model, controls, markup)
types/ # Domain type modules (anki, config, integrations, ...)
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows)
jimaku/ # Jimaku API integration helpers
subsync/ # Subtitle sync (alass/ffsubsync) helpers
subtitle/ # Subtitle processing utilities
tokenizers/ # Tokenizer implementations
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
token-mergers/ # Token merge strategies
translators/ # AI translation providers
```
### Service Layer (`src/core/services/`)
@@ -92,7 +94,7 @@ src/
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules (including `parser-enrichment-worker-runtime.ts` for async MeCab enrichment and `yomitan-parser-runtime.ts`)
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
- **Anki integration:** `anki-integration.ts`, `anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `anki-integration/note-update-workflow.ts`
- **Anki integration (repo `src/` root, not under `core/services/`):** `src/anki-integration.ts`, `src/anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `src/anki-integration/note-update-workflow.ts`
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
@@ -116,12 +118,19 @@ src/renderer/
handlers/
keyboard.ts # Keybindings, chord handling, modal key routing
mouse.ts # Hover/drag behavior, selection + observer wiring
gamepad-controller.ts # Gamepad/controller input handling
controller-binding-capture.ts # Controller binding capture flow
modals/
jimaku.ts # Jimaku modal flow
kiku.ts # Kiku field-grouping modal flow
runtime-options.ts # Runtime options modal flow
session-help.ts # Keyboard shortcuts/help modal flow
subsync.ts # Manual subsync modal flow
character-dictionary.ts # Character dictionary modal flow
playlist-browser.ts # Playlist browser modal flow
subtitle-sidebar.ts # Subtitle sidebar modal flow
youtube-track-picker.ts # YouTube subtitle track picker
controller-*.ts # Controller config/debug/select modals
utils/
dom.ts # Required DOM lookups + typed handles
platform.ts # Layer/platform capability detection
@@ -130,7 +139,7 @@ src/renderer/
### Launcher + Plugin Runtimes
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
- `plugin/subminer/main.lua` is the mpv entrypoint: it sets up the module path and loads `init.lua`, a thin shim that boots the modular Lua files: `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `session_bindings.lua` (configurable session keybindings), `version.lua` (version metadata). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
## Flow Diagram
@@ -313,7 +322,7 @@ The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to mpv subtitle/playback properties via `observe_property`), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through the `SubtitleProcessingController` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
```mermaid
@@ -380,7 +389,7 @@ flowchart TB
## Subtitle Prefetch Pipeline
SubMiner can pre-tokenize upcoming subtitle lines before they appear on screen. When an external subtitle file (SRT, VTT, or ASS) is detected on the active track, the `SubtitlePrefetchService` parses all cues via the `SubtitleCueParser`, identifies a priority window of upcoming lines based on the current playback position, and tokenizes them in the background through the same pipeline used for live subtitles. Results are stored directly into the `SubtitleProcessingController` cache, so when a subtitle actually appears during playback, it hits a warm cache and renders in ~30-50ms instead of ~200-320ms.
SubMiner can pre-tokenize upcoming subtitle lines before they appear on screen. When an external subtitle file (SRT, VTT, or ASS) is detected on the active track, the `SubtitlePrefetchService` parses all cues via the subtitle cue parser (`subtitle-cue-parser.ts`), identifies a priority window of upcoming lines based on the current playback position, and tokenizes them in the background through the same pipeline used for live subtitles. Results are stored directly into the `SubtitleProcessingController` cache, so when a subtitle actually appears during playback, it hits a warm cache and renders in ~30-50ms instead of ~200-320ms.
The prefetcher yields to live subtitle processing (which always takes priority over background work) and re-computes its priority window on seek. Cache invalidation events (e.g. marking a word as known) trigger re-prefetching of the current window to keep results fresh.
+148 -4
View File
@@ -1,6 +1,150 @@
# Changelog
## v0.15.2 (2026-06-02)
## v0.18.0 (2026-07-10)
**Added**
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
**Changed**
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
- App Icon: Replaced the app icon with new pixel-art submarine artwork contributed by the community, used across the app icon, tray, notifications, README, docs site, and Stats page.
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
**Fixed**
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
- Cover Art Fetching: Stats now fetches AniList cover art as soon as a new series starts playing, and backfills missing art for existing series on the next Stats visit.
- Kiku Field Grouping: The manual field-grouping dialog now stays above fullscreen mpv, remains usable across repeated attempts, closes abandoned windows after timeouts, and reports a clear error when the original card can no longer be loaded.
- Karaoke Subtitle Collapse: Secondary subtitles no longer stack dozens of one-syllable lines during openings and endings; repeated events collapse into one line, capped to a strip at the top.
- Unparsed Token Hover: Subtitle text Yomitan can't parse (truncated inflections, elongation runs) remains hoverable, while staying excluded from highlighting, N+1 candidate math, and vocabulary stats.
- YouTube Streaming: Fixed direct YouTube stream extraction that could corrupt signed URLs and cause ffmpeg 403 errors.
<details>
<summary>Internal changes</summary>
**Internal**
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
</details>
## Previous Versions
<details>
<summary>v0.17.x</summary>
<h2>v0.17.2 (2026-06-28)</h2>
**Fixed**
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
<h2>v0.17.1 (2026-06-27)</h2>
**Added**
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
**Fixed**
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
<h2>v0.17.0 (2026-06-15)</h2>
**Changed**
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
**Fixed**
- **Anki Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
- **Anki Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
- **Anki Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
- **Character Dictionary Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
- **Overlay macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
- **Overlay Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
- **Overlay Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
- **Overlay Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
- **Stats AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
- **Updates Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
**Docs**
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
<details>
<summary>Internal changes</summary>
**Internal**
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
</details>
</details>
<details>
<summary>v0.16.x</summary>
<h2>v0.16.0 (2026-06-10)</h2>
**Breaking Changes**
- **Notification Mode `both`**: `notificationType: "both"` now routes to overlay + system notifications. Users who previously used `"both"` for mpv OSD + system notifications should set `notificationType` to `"osd-system"` in `config.jsonc`. The `osd` and `osd-system` values remain valid as config-file entries but no longer appear in Settings.
**Added**
- **Overlay Notifications**: New overlay notification stack (Catppuccin Macchiato theme) with configurable screen position (`notifications.overlayPosition`: top-left, top-center, top-right), 3-second transient dismissals, and persistent cards for long-running jobs like character dictionary sync.
- **Notification History Panel**: `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`) opens a session log of all notifications. Works whether the overlay or mpv has focus; slides in from the notification edge; entries can be individually removed or cleared.
- **Mined-Card Notification Actions**: Mined-card overlay notifications show generated card thumbnails and include an Open in Anki button in both live cards and history entries.
- **Update Notification Action**: Update-available overlay notifications include an Update button to start the app update flow directly from the notification.
- **Stats Search**: New Search tab in Stats for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
**Changed**
- **AniSkip**: Moved intro detection from the mpv plugin to the SubMiner app. Lookups now cover every file loaded during a session including playlist advances, and `mpv.aniskipEnabled`/`mpv.aniskipButtonKey` hot-reload without restarting playback. The bundled plugin no longer makes network calls. Note: AniSkip now requires the SubMiner app to be connected; plugin-only mpv sessions will not fetch skip windows.
- **Stats Library**: Entries are now split by detected season (season folder first, filename parsing as fallback). Existing combined-series rows are automatically migrated to per-season entries on startup. Cover art and anime details refresh immediately after a manual AniList entry change.
- **Stats Vocabulary**: Remembers Hide Known/Hide Kana filters across sessions, applies Hide Kana filtering cross-title, collapses duplicate token variants in exclusions, and matches Related Seen Words by shared readings or kanji.
- **Stats Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
- **Stats Mining**: Sentence cards are created before slow media generation finishes; stored/requested secondary subtitles are preserved before falling back to sidecar or alass-retimed English subtitles; empty `ankiConnect.deck` falls back to Yomitan's mining deck; partial media failures are surfaced.
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
**Fixed**
- **AniList Completion**: Entries are now marked completed when a post-watch update reaches the final known episode of the season.
- **AniSkip Markers**: Fixed intro markers disappearing after same-media mpv reloads; fixed metadata detection for intros that start at 0 seconds and common release-group filenames.
- **Jellyfin Session**: Remote session now restarts after setup login so the websocket reconnects with fresh credentials, and stops cleanly on logout.
- **Sentence Card Audio**: Mining a sentence card no longer fills the expression audio field; generated audio goes only to the configured sentence audio field.
- **Stats Mining Fields**: Sentence clips update `SentenceAudio` correctly; word audio uses configured Yomitan sources; English subtitle text is not written to word cards; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- **Overlay Hover Readiness**: Subtitle bars are hoverable and clickable from the first subtitle line on visible overlay startup or resume, without waiting for the next subtitle event.
- **Startup Autoplay**: Playback is released after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
- **Overlay Startup Feedback**: Restored mpv OSD loading spinner that starts on connect, media open, or overlay request, and clears once the overlay is content-ready and visible.
- **Linux Overlay Input**: Notification close and action buttons remain clickable above subtitle bars on Linux.
<details>
<summary>Internal changes</summary>
**Internal**
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
</details>
</details>
<details>
<summary>v0.15.x</summary>
<h2>v0.15.2 (2026-06-02)</h2>
**Changed**
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
@@ -11,7 +155,7 @@
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
- Overlay (macOS): Fixed overlay, subtitles, and subtitle sidebar staying hidden after a modal closes until the user clicked the mpv window; focus is now restored to mpv when the last modal closes, so playback shortcuts and the overlay reappear correctly - including in native fullscreen.
## v0.15.1 (2026-05-31)
<h2>v0.15.1 (2026-05-31)</h2>
**Fixed**
@@ -27,7 +171,7 @@
- **Troubleshooting**: Updated Hyprland overlay docs with current Lua (`hl.window_rule`) and legacy config syntax; added troubleshooting for KDE/Wayland and other non-Hyprland/Sway Wayland sessions; added a Character Dictionary troubleshooting section; added a "See Also" index linking each feature's troubleshooting page.
## v0.15.0 (2026-05-29)
<h2>v0.15.0 (2026-05-29)</h2>
**Breaking Changes**
@@ -179,7 +323,7 @@
</details>
## Previous Versions
</details>
<details>
<summary>v0.14.x</summary>
+12 -6
View File
@@ -56,6 +56,8 @@ A single character produces many searchable terms so that names are recognized r
- Family name alone: 須々木
- Given name alone: 心一
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
**Middle-dot removal** (common in katakana foreign names):
- ア・リ・ス → アリス (combined), plus individual segments
@@ -132,7 +134,7 @@ Each character entry in the Yomitan dictionary includes structured content:
- **Name** - the matched Japanese name form
- **Known names** - generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
- **Role badge** - color-coded by role: main (score 100), supporting (90), side (80), background (70)
- **Role badge** - color-coded by role: main / "Protagonist" (score 100), primary / "Main Character" (75), side / "Side Character" (50), appears / "Minor Role" (25). AniList's MAIN maps to main, SUPPORTING to primary, and BACKGROUND to side.
- **Portrait** - character image from AniList, embedded in the ZIP
- **Description** - biography text from AniList (collapsible)
- **Character information** - age, birthday, gender, blood type (collapsible)
@@ -158,11 +160,13 @@ The three collapsible sections can be configured to start open or closed:
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import.
**Phases:**
1. **checking** - Is there already a cached snapshot for this media ID?
2. **generating** - No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON.
3. **syncing** - Add the media ID to the most-recently-used list. Evict old entries beyond `maxLoaded`.
3. MRU update (no notification) - add the media ID to the most-recently-used list and evict old entries beyond `maxLoaded`.
4. **building** - Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set - if it matches the previously imported revision, the import is skipped.
5. **importing** - Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
6. **ready** - Dictionary is live. Character names will match on the next subtitle line.
@@ -171,12 +175,14 @@ When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync rout
```jsonc
{
"activeMediaIds": [170942, 163134, 154587],
"activeMediaIds": ["170942 - Frieren", "163134 - ...", "154587 - ..."],
"mergedRevision": "a1b2c3d4e5f6",
"mergedDictionaryTitle": "SubMiner Character Dictionary",
}
```
(Entries are `"<mediaId> - <title>"` label strings; bare numeric IDs from older versions are still read.)
The `maxLoaded` setting (default: 3) controls how many media snapshots stay in the active set. When you start a 4th title, the oldest is evicted and the merged dictionary is rebuilt without it.
## Manual Generation
@@ -224,7 +230,7 @@ Manual selections are stored in `character-dictionaries/anilist-overrides.json`
Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
- **Remove** drops a non-current entry from the active merged dictionary and rebuilds/imports once.
- **Up/Down** changes MRU order for future eviction without rebuilding.
- **Up/Down** changes MRU order for future eviction; the merged dictionary is rebuilt and re-imported after a reorder.
- **Override** opens the AniList selector for that entry's title so you can replace a saved loaded entry.
The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
@@ -246,13 +252,13 @@ character-dictionaries/
m170942-va67890.jpg # Voice actor portrait
```
**Snapshot format** (v17): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
**Snapshot format** (v19, `CHARACTER_DICTIONARY_FORMAT_VERSION`): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images. Snapshots with a different format version are regenerated.
**ZIP structure** follows the Yomitan dictionary format:
```text
merged.zip
index.json # { title, revision, format: 3, author: "SubMiner" }
index.json # { title, revision, format: 3, author: "SubMiner", description }
tag_bank_1.json # Tag definitions
term_bank_1.json # Up to 10,000 terms per bank
term_bank_2.json
+213 -155
View File
@@ -46,13 +46,14 @@ The Settings window groups options by workflow instead of mirroring the raw conf
- Appearance
- Behavior
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes.
Playback-related fields live as sections inside these groups (for example "Playback Behavior" under **Behavior** and "mpv Playback" / "YouTube Playback Settings" under **Integrations**).
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
@@ -95,9 +96,11 @@ On macOS, these validation warnings also open a native dialog with full details
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
logging level, selected source-language preferences, Jimaku/Subsync settings, and
the Anki deck, known-word, N+1, field, sentence-card, and Kiku options listed
in the reference tables below.
shortcuts, notifications, logging level, selected source-language preferences,
Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default
mode, and the Anki deck, known-word, N+1, field, sentence-card, AI, and Kiku options
listed in the reference tables below.
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -158,6 +161,7 @@ The configuration file includes several main sections:
- [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
- [**Notifications**](#notifications) - Overlay notification placement
## Core Settings
@@ -187,6 +191,9 @@ Control the minimum log level for runtime output:
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
Log filenames use the local calendar date, for example `app-YYYY-MM-DD.log`, `launcher-YYYY-MM-DD.log`, and `mpv-YYYY-MM-DD.log`.
Log export creates a sanitized copy of those files; it does not rewrite the original log files on disk.
### Updates
Configure automatic update checks and update notifications:
@@ -196,18 +203,46 @@ Configure automatic update checks and update notifications:
"updates": {
"enabled": true,
"checkIntervalHours": 24,
"notificationType": "system",
"notificationType": "overlay",
"channel": "stable"
}
}
```
| Option | Values | Description |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
| Option | Values | Description |
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
`osd` and `osd-system` are legacy config-file-only notification values. The Settings window offers `overlay`, `system`, `both`, and `none`; if your config already contains `osd` or `osd-system`, it is shown as the selected value but not offered as a normal choice. If you previously used `both` for mpv OSD + system notifications, set `notificationType` to `"osd-system"` in `config.jsonc` to keep that behavior.
### Notifications
Configure where overlay notification cards appear:
```json
{
"notifications": {
"overlayPosition": "top-right"
}
}
```
| Option | Values | Description |
| ----------------- | ---------------------------------------- | ------------------------------------------------------------------ |
| `overlayPosition` | `"top-left"` \| `"top"` \| `"top-right"` | Position for in-overlay notification cards. Default `"top-right"`. |
#### Notification history panel
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
The equivalent direct CLI command is `--playback-feedback <text>` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config.
### Auto-Start Overlay
@@ -223,7 +258,7 @@ Control whether the overlay automatically becomes visible when it connects to mp
| -------------------- | --------------- | ----------------------------------------------------- |
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness.
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
@@ -360,7 +395,7 @@ See `config.example.jsonc` for detailed configuration options.
| Option | Values | Description |
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
@@ -450,6 +485,8 @@ Configure the parsed-subtitle sidebar modal.
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. |
Direct style keys are also available under `subtitleSidebar` and map to the same visuals as the CSS custom properties: `maxWidth` (default `420`), `opacity` (`0.95`), `backgroundColor`, `textColor`, `fontFamily`, `fontSize` (`16`), `timestampColor`, `activeLineColor`, `activeLineBackgroundColor`, and `hoverLineBackgroundColor`.
Sidebar CSS custom properties:
| CSS Property | Default | Description |
@@ -466,7 +503,7 @@ The sidebar is only available when the active subtitle source has been parsed in
For full details on layout modes, behavior, and the keyboard shortcut, see the [Subtitle Sidebar](/subtitle-sidebar) page.
`jlptColors` keys are:
`subtitleStyle.jlptColors` keys are:
| Key | Default | Description |
| ---- | --------- | ----------------------- |
@@ -476,12 +513,6 @@ For full details on layout modes, behavior, and the keyboard shortcut, see the [
| `N4` | `#8bd5ca` | JLPT N4 underline color |
| `N5` | `#8aadf4` | JLPT N5 underline color |
**Image Quality Notes:**
- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
- WebP quality uses FFmpeg's native 0-100 scale
### Subtitle Position
Set the initial vertical subtitle position (measured from the bottom of the screen):
@@ -516,11 +547,11 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
@@ -542,26 +573,29 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:**
| Key | Command | Description |
| -------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
| Key | Command | Description |
| ----------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
**Custom keybindings example:**
@@ -587,11 +621,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null }
```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
**See `config.example.jsonc`** for more keybinding examples and configuration options.
@@ -620,31 +654,35 @@ See `config.example.jsonc` for detailed configuration options.
"openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "Backslash",
"toggleNotificationHistory": "CommandOrControl+N",
"appendClipboardVideoToQueue": "CommandOrControl+A",
"multiCopyTimeoutMs": 3000
}
}
```
| Option | Values | Description |
| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
| Option | Values | Description |
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
| `appendClipboardVideoToQueue` | string \| `null` | Appends a video file path from the clipboard to the mpv playlist (default: `"CommandOrControl+A"`). Works whether the overlay or mpv has focus. |
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -760,7 +798,7 @@ If you bind a discrete action to an axis manually, include `direction`:
Treat the button-index map as reference-only unless you are copying values from the debug modal. Updating it alone does not rewrite the hardcoded raw numeric values already present in controller bindings or controller profiles. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `digital` forces pressed/not-pressed handling; `auto` accepts either style and remains the default.
If one controller reports non-standard raw button numbers, override that controller profile's button-index map using values from the `Alt+Shift+C` debug modal. Use the global button-index map only when the mapping should apply to every controller without a profile.
@@ -784,7 +822,7 @@ When automatic card updates are disabled, new cards are detected but not automat
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
| `Ctrl+D` | Open loaded character dictionary manager |
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (configurable via `shortcuts.appendClipboardVideoToQueue`) |
**Multi-line copy workflow:**
@@ -797,7 +835,7 @@ These shortcuts are only active when the overlay window is visible and automatic
### Session Help Modal
The session help modal opens from the overlay with `Ctrl/Cmd+/` by default. The mpv plugin also exposes it through the `Y-H` chord (falling back to `Y-K` if needed). It shows the current session keybindings and color legend.
The session help modal opens from the overlay with `Ctrl/Cmd+/` by default. The mpv plugin also exposes it through the `y-h` chord. It shows the current session keybindings and color legend.
You can filter the modal quickly with `/`:
@@ -824,8 +862,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
Current runtime options cover automatic card updates, known-word highlighting,
JLPT underlines, frequency highlighting, known-word match mode, and Kiku field
grouping mode.
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
and Kiku field grouping mode.
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
@@ -842,7 +880,7 @@ Palette controls:
### Shared AI Provider
This is the single, shared connection to an OpenAI-compatible LLM endpoint. Configure it **once** here at the top level, and SubMiner reuses it wherever AI is needed (today: Anki translation/enrichment). Per-feature toggles and prompt/model tweaks live in their own sections (for example `ankiConnect.ai`) and inherit this transport.
This is the single, shared connection to an OpenAI-compatible LLM endpoint. Configure it **once** here at the top level, and SubMiner reuses it wherever AI is needed (Anki translation/enrichment and YouTube subtitle fixing). Per-feature toggles and prompt/model tweaks live in their own sections (for example `ankiConnect.ai` and `youtubeSubgen.ai`) and inherit this transport.
```json
{
@@ -870,6 +908,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
SubMiner uses the shared provider for:
- Anki translation/enrichment when Anki AI is enabled
- YouTube generated-subtitle fixing when `youtubeSubgen.fixWithAi` is enabled (with optional `youtubeSubgen.ai.model` / `systemPrompt` overrides)
### AnkiConnect
@@ -914,6 +953,8 @@ Enable automatic Anki card creation and updates with media generation:
"animatedMaxWidth": 640,
"animatedMaxHeight": 0,
"animatedCrf": 35,
"normalizeAudio": true,
"mirrorMpvVolume": true,
"audioPadding": 0,
"fallbackDuration": 3,
"maxMediaDuration": 30
@@ -943,57 +984,59 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
| Option | Values | Description |
| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`). JPG values are mapped onto FFmpeg's 2-31 quality scale; WebP uses the value directly. |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `<br>`=newline |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1095,8 +1138,6 @@ Configure Jimaku API access and defaults:
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
Set `openBrowser` to `false` to only print the URL without opening a browser.
### Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
@@ -1117,11 +1158,13 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
| Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable.
@@ -1152,6 +1195,8 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
@@ -1380,9 +1425,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
You can also disable immersion tracking for a single session using:
@@ -1433,7 +1478,7 @@ Usage notes:
- The browser UI is served at `http://127.0.0.1:<serverPort>`.
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
### MPV Launcher
@@ -1456,18 +1501,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
}
```
| Option | Values | Description |
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
| Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
@@ -1484,14 +1529,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
```json
{
"youtube": {
"primarySubLanguages": ["ja", "jpn"]
"primarySubLanguages": ["ja", "jpn"],
"mediaCache": {
"mode": "direct",
"maxHeight": 720
}
}
}
```
| Option | Values | Description |
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
| Option | Values | Description |
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
Current launcher behavior:
@@ -1499,15 +1554,18 @@ Current launcher behavior:
- If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side.
- SubMiner loads the primary subtitle plus a best-effort secondary subtitle.
- Playback waits only for primary subtitle readiness; secondary failures do not block playback.
- English secondary subtitles are selected from the secondary-subtitle language list when primary language matches are unavailable.
- Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface.
- If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track.
Language targets are derived from subtitle config:
Track selection:
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
- secondary track: secondary-subtitle language list (falls back to English when empty)
- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
- YouTube auto-selection always targets a Japanese primary track and an English secondary track, preferring manual uploads over auto-generated captions.
- `youtube.primarySubLanguages` (default `["ja","jpn"]`) defines which loaded track counts as a satisfactory primary for the "primary subtitle missing" notification and for managed local/playlist subtitle selection.
- Local playback applies these priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default.
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
#### YouTube Subtitle Generation (`youtubeSubgen`)
An advanced, template-hidden section for Whisper-based YouTube subtitle generation: `whisperBin`, `whisperModel`, `whisperVadModel`, `whisperThreads` (default `4`), and `fixWithAi` (default `false`), which post-processes generated subtitles through the [Shared AI Provider](#shared-ai-provider) with optional `youtubeSubgen.ai.model` / `systemPrompt` overrides. These keys are accepted in `config.jsonc` but intentionally omitted from the generated template.
+19 -25
View File
@@ -5,21 +5,18 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
## Prerequisites
- [Bun](https://bun.sh)
- A system `lua` interpreter for `bun run test:launcher` / `bun run test:plugin:src`
- macOS builds compile a Swift helper via `scripts/build-macos-helper.sh` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`)
## Setup
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
# if you cloned without --recurse-submodules:
git submodule update --init --recursive
bun install
(cd stats && bun install --frozen-lockfile)
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
make deps
```
`make deps` is still available as a convenience wrapper around the same dependency install flow.
`make deps` initializes submodules and installs root, `stats/`, and `vendor/texthooker-ui` dependencies. The Yomitan submodule installs its own dependencies on demand during `bun run build`.
## Building
@@ -85,18 +82,24 @@ Default lanes:
```bash
bun run test # alias for test:fast
bun run test:fast # default fast lane
bun run test:full # maintained source + launcher-unit + runtime compat surface
bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
bun run test:runtime:compat # compiled/runtime compatibility slice only
bun run test:env # launcher/plugin + env-sensitive verification
bun run test:stats # stats dashboard UI suite
bun run test:immersion:sqlite # SQLite persistence lane
bun run test:subtitle # maintained alass/ffsubsync subtitle surface
```
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks.
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`.
Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
runs each test file in its own `bun test` process (per-file isolation) so a hanging
test or leaked global in one file cannot cascade into the rest of the lane; pass
`--jobs N` to parallelize or `--single-process` for one shared process.
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
@@ -131,11 +134,11 @@ Focused commands:
```bash
bun run test:config # Source-level config schema/validation tests
bun run test:launcher # Launcher regression tests (config discovery + command routing)
bun run test:core # Source-level core regression tests (default lane)
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
bun run test:src # Bun-managed maintained src/** discovery lane
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
bun run test:scripts # Bun-managed scripts/** test lane
bun run test:immersion:sqlite:src # Bun source lane
```
@@ -149,8 +152,6 @@ Smoke and optional deep dist commands:
bun run build # compile dist artifacts
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
bun run test:smoke:dist # explicit smoke scope for compiled runtime
bun run test:config:dist # optional full dist config suite
bun run test:core:dist # optional full dist core suite
```
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
@@ -164,7 +165,7 @@ make pretty
bun run format:check:src
```
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
- `make pretty` runs the maintained Prettier allowlists (`format:src` and `format:stats`).
- `bun run format:check:src` checks the same scoped set without writing changes.
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
@@ -197,15 +198,7 @@ bun run docs:preview # Preview built site at http://localhost:4173
bun run docs:test # Docs regression tests
```
Cloudflare Pages deploy settings:
- Git repo: `ksyasuda/SubMiner`
- Root directory: `docs-site`
- Build command: `bun run docs:build`
- Build output directory: `.vitepress/dist`
- Build watch paths: `docs-site/*`
Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` covers nested docs-site changes in the repo; `docs-site/**` is not the correct Pages pattern and may skip docs-only pushes.
Deployment: production docs are built with `bun run docs:build:versioned` and uploaded directly to Cloudflare Pages by the `docs-pages` GitHub Actions workflow using Wrangler (from `.tmp/docs-versioned-site`). Cloudflare's automatic Git-integration deployments are intentionally disabled - see `docs-site/README.md` for the deployment contract. Do not re-enable Pages build settings in the Cloudflare dashboard.
## Makefile Reference
@@ -216,7 +209,7 @@ Run `make help` for a full list of targets. Key ones:
| `make build` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
| `make deps` | Init submodules and install root/stats/texthooker-ui deps |
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
| `make generate-config` | Generate default config from centralized registry |
| `make build-linux` | Convenience wrapper for Linux packaging |
@@ -242,6 +235,7 @@ Run `make help` for a full list of targets. Key ones:
| `SUBMINER_APPIMAGE_PATH` | Override SubMiner app binary path for launcher playback commands |
| `SUBMINER_BINARY_PATH` | Alias for `SUBMINER_APPIMAGE_PATH` |
| `SUBMINER_ROFI_THEME` | Override rofi theme path for launcher picker |
| `SUBMINER_MPV_PLUGIN_PATH` | Override the mpv plugin directory injected by the launcher |
| `SUBMINER_LOG_LEVEL` | Override app logger level (`debug`, `info`, `warn`, `error`) |
| `SUBMINER_MPV_LOG` | Override mpv/app shared log file path |
| `SUBMINER_JIMAKU_API_KEY` | Override Jimaku API key for launcher subtitle downloads |
+63 -46
View File
@@ -18,22 +18,23 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
{
"immersionTracking": {
"enabled": true,
"dbPath": ""
}
"dbPath": "",
},
}
```
- Leave `dbPath` empty to use the default location (`immersion.sqlite` in SubMiner's app-data directory).
- Set an explicit path to move the database (useful for backups, cloud syncing, or external tools).
- To share stats and watch history between two machines, use [`subminer sync <host>`](/launcher-script#sync-between-machines) instead of file-level cloud sync — it merges both databases without one side overwriting the other.
## Stats Dashboard
The same immersion data powers the stats dashboard.
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser.
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
- Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand.
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables. `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
### Dashboard Tabs
@@ -48,13 +49,19 @@ Recent sessions, streak calendar, watch-time history, and a tracking snapshot wi
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card.
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
![Stats Library](/screenshots/stats-library.png)
#### Trends
Watch time, sessions, words seen, and per-anime progress/pattern charts with configurable date ranges and grouping.
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
![Stats Trends](/screenshots/stats-trends.png)
@@ -66,26 +73,32 @@ Expandable session history with new-word activity, cumulative totals, and pause/
#### Vocabulary
Top repeated words (click a bar to open the word), new-word timeline, frequency rank table with full readings, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
![Stats Vocabulary](/screenshots/stats-vocabulary.png)
#### Search
Realtime search across tracked primary subtitle lines and media titles. Results show the source media, session, line number, timing, and sentence text. Secondary subtitle text is not shown or searched here because separate subtitle tracks may not line up sentence-for-sentence. Sentence cards can be mined from any result with a valid local source and timing. Word and audio card buttons appear only when the searched word exactly appears in the primary sentence text; matching text is highlighted in the result.
Stats server config lives under `stats`:
```jsonc
{
"stats": {
"toggleKey": "Backquote",
"markWatchedKey": "KeyW",
"serverPort": 6969,
"autoStartServer": true,
"autoOpenBrowser": false
}
"autoOpenBrowser": false,
},
}
```
- `toggleKey` is overlay-local, not a system-wide shortcut.
- `markWatchedKey` toggles the watched state of the highlighted entry inside the stats dashboard.
- `serverPort` controls the localhost dashboard URL.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
@@ -96,15 +109,15 @@ Stats server config lives under `stats`:
## Mining Cards from the Stats Page
The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons:
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and used as the translation field when mining from the stats page.
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
### Word Exclusion List
@@ -114,12 +127,12 @@ The Vocabulary tab toolbar includes an **Exclusions** button for hiding words fr
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
| Data type | Retention |
| -------------- | --------- |
| Raw events | 0 (keep all) |
| Telemetry | 0 (keep all) |
| Sessions | 0 (keep all) |
| Daily rollups | 0 (keep all) |
| Data type | Retention |
| --------------- | ------------ |
| Raw events | 0 (keep all) |
| Telemetry | 0 (keep all) |
| Sessions | 0 (keep all) |
| Daily rollups | 0 (keep all) |
| Monthly rollups | 0 (keep all) |
Maintenance runs on startup and every 24 hours. Vacuum runs only when `retention.vacuumIntervalDays` is non-zero.
@@ -146,24 +159,24 @@ The tracker is optimized for "keep everything" defaults:
All policy options live under `immersionTracking` in your config:
| Option | Description |
| ------ | ----------- |
| `batchSize` | Writes per flush batch |
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
| `queueCap` | Max queued writes before oldest are dropped |
| `payloadCapBytes` | Max payload size per write |
| `maintenanceIntervalMs` | How often maintenance runs |
| `retention.eventsDays` | Raw event retention |
| `retention.telemetryDays` | Telemetry retention |
| `retention.sessionsDays` | Session retention |
| `retention.dailyRollupsDays` | Daily rollup retention |
| `retention.monthlyRollupsDays` | Monthly rollup retention |
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
| `retentionMode` | `preset` or `advanced` |
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
| `lifetimeSummaries.global` | Maintain global lifetime totals |
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
| Option | Description |
| ------------------------------ | ------------------------------------------------------------------ |
| `batchSize` | Writes per flush batch |
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
| `queueCap` | Max queued writes before oldest are dropped |
| `payloadCapBytes` | Max payload size per write |
| `maintenanceIntervalMs` | How often maintenance runs |
| `retention.eventsDays` | Raw event retention |
| `retention.telemetryDays` | Telemetry retention |
| `retention.sessionsDays` | Session retention |
| `retention.dailyRollupsDays` | Daily rollup retention |
| `retention.monthlyRollupsDays` | Monthly rollup retention |
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
| `retentionMode` | `preset` or `advanced` |
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
| `lifetimeSummaries.global` | Maintain global lifetime totals |
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
## Query Templates
@@ -175,7 +188,6 @@ SELECT
total_watched_ms,
active_watched_ms,
lines_seen,
words_seen,
tokens_seen,
cards_mined
FROM imm_session_telemetry
@@ -193,13 +205,13 @@ SELECT
s.started_at_ms,
s.ended_at_ms,
COALESCE(s.active_watched_ms, 0) AS active_watched_ms,
COALESCE(s.words_seen, 0) AS words_seen,
COALESCE(s.tokens_seen, 0) AS tokens_seen,
COALESCE(s.cards_mined, 0) AS cards_mined,
CASE
WHEN COALESCE(s.active_watched_ms, 0) > 0
THEN COALESCE(s.words_seen, 0) / (COALESCE(s.active_watched_ms, 0) / 60000.0)
THEN COALESCE(s.tokens_seen, 0) / (COALESCE(s.active_watched_ms, 0) / 60000.0)
ELSE NULL
END AS words_per_min,
END AS tokens_per_min,
CASE
WHEN COALESCE(s.active_watched_ms, 0) > 0
THEN (COALESCE(s.cards_mined, 0) * 60.0) / (COALESCE(s.active_watched_ms, 0) / 60000.0)
@@ -219,7 +231,7 @@ SELECT
la.total_sessions,
la.total_active_ms,
la.total_cards,
la.total_words_seen,
la.total_tokens_seen,
la.total_lines_seen,
la.first_watched_ms,
la.last_watched_ms
@@ -238,11 +250,10 @@ SELECT
total_sessions,
total_active_min,
total_lines_seen,
total_words_seen,
total_tokens_seen,
total_cards,
cards_per_hour,
words_per_min,
tokens_per_min,
lookup_hit_rate
FROM imm_daily_rollups
ORDER BY rollup_day DESC, video_id DESC
@@ -258,7 +269,6 @@ SELECT
total_sessions,
total_active_min,
total_lines_seen,
total_words_seen,
total_tokens_seen,
total_cards
FROM imm_monthly_rollups
@@ -277,15 +287,19 @@ LIMIT ?;
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
### Schema (v4)
### Schema (v18)
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
Core tables:
- `imm_videos` - video key/title/source metadata
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
- `imm_session_telemetry` - high-frequency session aggregates over time
- `imm_session_events` - event stream with compact numeric event types
- `imm_subtitle_lines` - persisted subtitle text and timing per session/video
- `imm_youtube_videos` - YouTube video/channel metadata for tracked videos
Lifetime summary tables:
@@ -298,11 +312,14 @@ Rollup tables:
- `imm_daily_rollups`
- `imm_monthly_rollups`
- `imm_rollup_state` - incremental rollup progress bookkeeping
Vocabulary tables:
- `imm_words(id, headword, word, reading, first_seen, last_seen, frequency)`
- `imm_words(id, headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency, frequency_rank)` with `UNIQUE(headword, word, reading)`
- `imm_kanji(id, kanji, first_seen, last_seen, frequency)`
- `imm_word_line_occurrences` / `imm_kanji_line_occurrences` - word/kanji ↔ subtitle-line occurrence links
- `imm_stats_excluded_words` - vocabulary exclusion list managed from the dashboard
Media-art tables:
+31 -26
View File
@@ -12,20 +12,20 @@ Three steps to get started:
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| Dependency | Status | What it does |
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
### Linux
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
bun install
make deps
bun run build
# Optional: build AppImage
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
git submodule update --init --recursive
make deps
make build-macos
```
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
```powershell
git clone https://github.com/ksyasuda/SubMiner.git
cd SubMiner
git submodule update --init --recursive
bun install
# Windows requires building texthooker-ui manually before the main build
Set-Location vendor/texthooker-ui
Set-Location stats
bun install --frozen-lockfile
Set-Location ../vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
Set-Location ../..
bun run build:win
```
@@ -300,11 +300,11 @@ subminer -u
subminer --update
```
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. 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`; 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.
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
## How It All Fits Together
@@ -312,13 +312,14 @@ 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 bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. 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
### macOS
**MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
@@ -361,17 +362,21 @@ sudo chmod +x /usr/local/bin/subminer
## Optional Extras
### Rofi Theme (Linux Only)
### Linux Support Assets
SubMiner ships a custom rofi theme in the release assets:
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
```bash
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
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
```
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
`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`.
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
+2 -1
View File
@@ -79,6 +79,7 @@ SubMiner extracts media info from the current video path to pre-fill the search
- **Season + episode patterns:** `S01E03`, `1x03`
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
- **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
- **Year tags:** `(2024)` - stripped
- **Dots and underscores:** treated as spaces
@@ -94,7 +95,7 @@ Configure `jimaku.apiKey` or `jimaku.apiKeyCommand` in your config. If using `ap
**"Jimaku request failed" or HTTP 429**
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. An API key provides higher rate limits.
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again.
**No entries found**
+73 -19
View File
@@ -34,15 +34,19 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
subminer -R /directory # rofi picker, directory shortcut
```
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme bundled 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
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
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
```
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.
The theme is auto-detected from these paths (first match wins):
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
@@ -50,6 +54,7 @@ The theme is auto-detected from these paths (first match wins):
- `/usr/local/share/SubMiner/themes/subminer.rasi`
- `/usr/share/SubMiner/themes/subminer.rasi`
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
- `assets/themes/subminer.rasi` next to the launcher script (final fallback)
Override with the `SUBMINER_ROFI_THEME` environment variable:
@@ -57,10 +62,54 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
```
## Watch History
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
```bash
subminer -H # fzf history browser
subminer -R -H # rofi history browser
```
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Replay last watched** — replays the most recently watched episode
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
## Sync Between Machines
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version.
```bash
subminer sync macbook # two-way sync with the host "macbook"
subminer sync macbook --push # merge local data into macbook only
subminer sync macbook --pull # merge macbook data into local only
subminer sync user@192.168.1.20 # explicit user@host
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
```
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror.
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch. Remote sync checks standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`.
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
```bash
subminer sync --snapshot /tmp/stats.sqlite # write a consistent snapshot of the local database
subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the local database
```
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection.
## Common Commands
```bash
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
subminer video.mkv # play a specific file (managed launches auto-start the visible overlay by default)
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
subminer --backend x11 video.mkv # Force x11 backend for a specific file
subminer -u # check for SubMiner updates
@@ -73,24 +122,28 @@ subminer stats -b # start background stats daemon
| Subcommand | Purpose |
| ------------------------------------------ | ------------------------------------------------------------------ |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
| `subminer stats` | Start stats server and open immersion dashboard in browser |
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
| `subminer settings` | Open the SubMiner settings window |
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
| `subminer mpv socket` | Print active socket path |
| `subminer mpv idle` | Launch detached idle mpv instance |
| `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target |
| `subminer sync <host>` | Two-way stats/history sync with another machine over SSH |
| `subminer sync <host> --push` | Merge local stats/history into another machine only |
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
| `subminer dictionary <path>` / `dict` | Generate character dictionary ZIP from file/dir target |
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
| `subminer texthooker` | Launch texthooker-only mode |
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
| `subminer app` | Pass arguments directly to SubMiner binary |
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
Use `subminer <subcommand> -h` for command-specific help.
@@ -101,24 +154,25 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `--setup` | Open first-run setup popup manually |
| `-v, --version` | Print installed SubMiner version |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Force the visible overlay on start |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--settings` | Open the SubMiner settings window |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, runtime plugin copy, and rofi theme even when SubMiner is already running in the tray.
Managed launches inject `auto_start=yes`, `auto_start_visible_overlay=yes`, and `auto_start_pause_until_ready=yes` as plugin script-opts from SubMiner's config defaults (`mpv.autoStartSubMiner`, `auto_start_overlay`), so explicit start flags are usually unnecessary. The plugin's own built-in defaults are off - mpv launched outside SubMiner does not auto-start the overlay.
## Logging
- Default log level is `info`
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
- `--dev` / `--debug` control app behavior, not logging verbosity - use `--log-level` for that
- Default log level is `warn` (launcher and app; configurable via `logging.level`)
- `--dev` / `--debug` are app-binary flags that control app dev-mode, not logging verbosity - use `--log-level` for that
+12 -10
View File
@@ -4,7 +4,7 @@ This guide walks through the sentence mining loop - from watching a video to cre
## Overview
*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
@@ -56,20 +56,20 @@ This is useful when auto-update is disabled or when you want explicit control ov
Create a standalone sentence card without going through Yomitan:
- **Mine current sentence**: `Ctrl/Cmd+S` (configurable via `shortcuts.mineSentence`)
- **Mine multiple lines**: `Ctrl/Cmd+Shift+S` followed by a digit 19 to select how many recent subtitle lines to combine.
- **Mine multiple lines**: `Ctrl/Cmd+Shift+S` followed by a digit 19 to select how many recent subtitle lines to combine (the digit selector times out after 3 seconds, configurable via `shortcuts.multiCopyTimeoutMs`).
The sentence card uses the note type configured in `isLapis.sentenceCardModel` and always maps sentence/audio to `Sentence` and `SentenceAudio`.
::: warning Requires Lapis/Kiku note type
Sentence card creation requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
Sentence card creation requires `ankiConnect.isLapis.sentenceCardModel` to name a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type that exists in Anki (default: `"Lapis"`). See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
:::
### 4. Mark as Audio Card
After adding a word via Yomitan, press the audio card shortcut to overwrite the audio with a longer clip spanning the full subtitle timing.
After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+A` by default, `shortcuts.markAudioCard`) to mark the card as an audio card. This sets the audio-card flag and fills sentence, image, and metadata fields alongside the full-subtitle audio clip.
::: warning Requires Lapis/Kiku note type
Audio card marking requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
Audio card marking uses the same `ankiConnect.isLapis.sentenceCardModel` note type as sentence cards. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
:::
### Field Grouping (Kiku)
@@ -122,10 +122,10 @@ By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`)
Cycle each bar's mode at runtime with its own shortcut:
| Shortcut | Action | Config key |
| -------------------- | -------------------------------------------------------- | ------------------------------ |
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ------------------------------ |
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
### Modal Surfaces
@@ -166,11 +166,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
## Texthooker
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools - such as a browser-based Yomitan instance - to receive subtitle text in real time.
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (fixed default port; overridable only via the mpv plugin's `texthooker_port` script-opt) that serves a texthooker UI. This allows external tools - such as a browser-based Yomitan instance - to receive subtitle text in real time.
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
+61 -43
View File
@@ -1,15 +1,17 @@
# MPV Plugin
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs *inside* mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
**Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `init.lua`, which loads `main.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `main.lua`, which loads `init.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
## Runtime Loading
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
On Linux, the launcher-managed runtime plugin copy lives under the SubMiner data dir (`$XDG_DATA_HOME/SubMiner/plugin/subminer` by default, plus `/usr/local/share/SubMiner` or `/usr/share/SubMiner` for system installs). `subminer -u` and the tray updater keep that managed copy current. This is separate from mpv's global `scripts/` directory.
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
mpv must have IPC enabled for SubMiner to connect:
@@ -25,24 +27,43 @@ On Windows, use a named pipe instead:
input-ipc-server=\\.\pipe\subminer-socket
```
## Configuration (script-opts)
The plugin reads options from `script-opts` with the `subminer-` prefix (for example `--script-opts=subminer-backend=hyprland`). Managed launches inject these automatically from your SubMiner config; the shipped `subminer.conf` is intentionally empty so command-line opts always win.
| Option | Default | Description |
| -------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------- |
| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
| `texthooker_port` | `5174` | Texthooker server port |
| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
| `osd_messages` | `yes` | Show plugin OSD status messages |
| `log_level` | `info` | Plugin log verbosity |
## Keybindings
Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"):
| Chord | Action |
| ---------------- | -------------------------------------- |
| `y-y` | Open menu |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `y-o` | Open settings window |
| `y-r` | Restart overlay |
| `y-c` | Check status |
| `y-h` | Open session help / keybinding modal |
| `v` | Toggle primary subtitle bar visibility |
| `TAB` (default) | Skip intro (AniSkip) |
| Chord | Action |
| --------------- | -------------------------------------- |
| `y-y` | Open menu |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `y-o` | Open settings window |
| `y-r` | Restart overlay |
| `y-c` | Check status |
| `y-h` | Open session help / keybinding modal |
| `v` | Toggle primary subtitle bar visibility |
| `TAB` (default) | Skip intro (AniSkip) |
The AniSkip key is **not** a `y` chord. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it.
The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMiner app binds it over the mpv IPC socket while it is connected. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. When a custom key (other than `TAB` or `y-k`) is configured, the legacy `y-k` chord is also bound as a fallback. See [AniSkip Integration](/aniskip-integration) for setup and details.
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
@@ -68,7 +89,7 @@ Notes:
## Menu
Press `y-y` to open an interactive menu in mpv's OSD:
Press `y-y` to open an interactive menu (rendered with mpv's console selector):
```text
SubMiner:
@@ -78,6 +99,7 @@ SubMiner:
4. Open options
5. Restart overlay
6. Check status
7. Stats
```
Select an item by pressing its number.
@@ -90,8 +112,8 @@ When `binary_path` is empty, the plugin searches platform-specific locations:
1. `~/.local/bin/SubMiner.AppImage`
2. `/opt/SubMiner/SubMiner.AppImage`
3. `/usr/local/bin/SubMiner`
4. `/usr/bin/SubMiner`
3. `/usr/local/bin/SubMiner` / `/usr/local/bin/subminer`
4. `/usr/bin/SubMiner` / `/usr/bin/subminer`
**macOS:**
@@ -100,11 +122,14 @@ When `binary_path` is empty, the plugin searches platform-specific locations:
**Windows:**
1. `C:\Program Files\SubMiner\SubMiner.exe`
2. `C:\Program Files (x86)\SubMiner\SubMiner.exe`
3. `C:\SubMiner\SubMiner.exe`
A PowerShell system lookup runs first (running SubMiner process, registry App Paths, `Get-Command`), then static paths:
Packaged Windows plugin installs also rewrite `socket_path` to `\\.\pipe\subminer-socket` automatically.
1. `%LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe` (the default per-user install location)
2. `C:\Program Files\SubMiner\SubMiner.exe`
3. `C:\Program Files (x86)\SubMiner\SubMiner.exe`
4. `C:\SubMiner\SubMiner.exe`
On Windows the plugin also normalizes a Unix-style `socket_path` (`/tmp/subminer-socket`) to the named pipe `\\.\pipe\subminer-socket` at runtime.
## Backend Detection
@@ -133,10 +158,18 @@ script-message subminer-options
script-message subminer-restart
script-message subminer-status
script-message subminer-autoplay-ready
script-message subminer-aniskip-refresh
script-message subminer-skip-intro
script-message subminer-stats-toggle
script-message subminer-visible-overlay-shown
script-message subminer-visible-overlay-hidden
script-message subminer-managed-subtitles-loading
script-message subminer-overlay-loading-ready
script-message subminer-reload-session-bindings
```
The last five are primarily used by the SubMiner app to notify the plugin of overlay/loading state and to trigger session-binding reloads.
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) still exist, but they are handled by the SubMiner app over the IPC socket rather than by the plugin - see [AniSkip Integration](/aniskip-integration#triggering-from-mpv).
The `subminer-start` message accepts overrides:
```
@@ -146,30 +179,15 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
`log-level` here controls only logging verbosity passed to SubMiner.
`--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging.
## AniSkip Intro Skip
- AniSkip lookups are gated. The plugin only runs lookup when:
- SubMiner launcher metadata is present, or
- SubMiner app process is already running, or
- You explicitly call `script-message subminer-aniskip-refresh`.
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
- MAL/title resolution is cached for the current mpv session.
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing TAB` by default; the key reflects `mpv.aniskipButtonKey`).
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
## Lifecycle
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. On cold managed background startup, SubMiner opens the tray and visible overlay shell before tokenization warmups finish, then the plugin resumes playback after SubMiner reports tokenization-ready (with a 30-second timeout fallback).
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
- **MPV shutdown**: The plugin clears its hover/OSD/gate state on shutdown; the overlay app notices the closed IPC socket and shuts itself down.
- **Texthooker**: When `texthooker_enabled=yes`, the plugin appends `--texthooker` to the overlay start command so the app starts the texthooker server alongside the overlay.
## Using with the `subminer` Wrapper
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

+67 -13
View File
@@ -172,10 +172,19 @@
"updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior.
// ==========================================
// Notifications
// Overlay notification display behavior.
// Hot-reload: position changes apply to the next overlay notification.
// ==========================================
"notifications": {
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -196,10 +205,13 @@
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openAnimetosho": "Ctrl+Shift+T", // Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
"appendClipboardVideoToQueue": "CommandOrControl+A" // Accelerator that appends a video path from the clipboard to the mpv playlist.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -280,15 +292,41 @@
] // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
"command": [
"__sub-delay-next-line"
"sub-step",
-1
] // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"key": "Ctrl+Shift+ArrowRight", // Key setting.
"command": [
"__sub-delay-prev-line"
"sub-step",
1
] // Command setting.
},
{
"key": "KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
-0.1
] // Command setting.
},
{
"key": "Shift+KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
"key": "KeyX", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
@@ -479,7 +517,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -496,7 +534,7 @@
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
@@ -523,6 +561,8 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
@@ -532,14 +572,14 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
@@ -573,6 +613,16 @@
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
// Animetosho
// Animetosho subtitle search configuration (English and Japanese). No API key required.
// Hot-reload: Animetosho changes apply to the next Animetosho request.
// ==========================================
"animetosho": {
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required.
"maxSearchResults": 10 // Maximum Animetosho search results returned.
}, // Animetosho subtitle search configuration (English and Japanese). No API key required.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
@@ -582,7 +632,11 @@
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"mediaCache": {
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
} // Media cache setting.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ==========================================
@@ -634,8 +688,8 @@
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
}, // SubMiner-managed mpv launch and bundled plugin options.
// ==========================================
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+57 -54
View File
@@ -10,17 +10,15 @@ A few terms used throughout:
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
## Global Shortcuts
## App-Wide Shortcuts
These work system-wide regardless of which window has focus.
| Shortcut | Action | Configurable |
| ------------- | ---------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
| Shortcut | Action | Scope | Configurable |
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
::: tip
Global shortcuts are registered with the OS. If they conflict with another application, update them in `shortcuts` config and restart SubMiner.
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
:::
## Mining Shortcuts
@@ -43,31 +41,33 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action |
| -------------------- | --------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `F` | Toggle fullscreen |
| Shortcut | Action |
| -------------------- | ---------------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `F` | Toggle fullscreen |
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
| `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv |
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
| `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle |
| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
| `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue |
| `z` | Shift subtitles 100 ms earlier |
| `Shift+Z` | Delay subtitles by 100 ms |
| `x` | Delay subtitles by 100 ms |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv |
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
@@ -75,18 +75,21 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Shift+T` | Open Animetosho subtitle search modal (EN/JA tabs) | `shortcuts.openAnimetosho` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
@@ -105,19 +108,19 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
## MPV Plugin Chords
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key (the overlay-side chord times out after 1 second; the mpv plugin uses native mpv key sequences).
| Chord | Action |
| ----- | -------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| Chord | Action |
| ----- | ---------------------------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
+23 -19
View File
@@ -10,6 +10,8 @@ SubMiner's primary tokenizer is Yomitan itself - subtitle text is tokenized base
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
Kanji vocabulary that MeCab labels `名詞/非自立`, such as `日` or `以外`, remains content for every annotation layer. The `非自立` exclusion only suppresses kana grammar nouns such as `こと` and `もの`.
## N+1 Word Highlighting
N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly.
@@ -24,16 +26,16 @@ N+1 highlighting identifies sentences where you know every word except one, maki
**Key settings:**
| Option | Default | Description |
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
| Option | Default | Description |
| ----------------------------------------- | ------------ | -------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only fields can mark unrelated homophones as known, so only include them when that tradeoff is intentional.
@@ -64,7 +66,7 @@ For full details on dictionary generation, name variant expansion, auto-sync lif
## Frequency Highlighting
Frequency highlighting colors tokens based on how common they are, using dictionary frequency rank data. This helps you spot high-value vocabulary at a glance. Frequency ranks are sourced from the **highest-ranked frequency dictionary** installed in Yomitan - other frequency dictionaries are not consulted.
Frequency highlighting colors tokens based on how common they are, using dictionary frequency rank data. This helps you spot high-value vocabulary at a glance. For each token, ranks from the installed Yomitan frequency dictionaries are consulted in priority order: the highest-priority dictionary that has the term wins, lower-priority dictionaries fill in terms it lacks, and occurrence-based dictionaries are skipped.
**Modes:**
@@ -90,7 +92,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
::: info
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist.
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist. For merged kana tokens, SubMiner keeps a rank when the dictionary headword reading covers the full token (for example, `かと言って` / `かといって`), while grammar wrapped around a shorter lemma remains unannotated.
:::
::: info
@@ -126,22 +128,24 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
## Runtime Toggles
All annotation layers can be toggled at runtime via the mpv command menu without restarting:
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
- `subtitleStyle.nameMatchImagesEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
(Character-name matching, `subtitleStyle.nameMatchEnabled`, is toggled through config or the Settings window, not the runtime palette.)
Toggles only apply to new subtitle lines after the change - the currently displayed line is not re-tokenized in place.
## Rendering Priority
When multiple annotations apply to the same token, the visual priority is:
1. **N+1 target** (highest) - the single unknown word in an N+1 sentence
2. **Character-name match** - dictionary-driven character-name token styling
1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations
2. **N+1 target** - the single unknown word in an N+1 sentence
3. **Known-word color** - already-learned token tint
4. **Frequency highlight** - common-word coloring (not applied when N+1/character-name/known-word already matched)
5. **JLPT underline** - level-based underline (stacks with the above since it uses underline rather than text color)
4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched)
5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match)
+11 -11
View File
@@ -126,7 +126,7 @@ The detected launcher is installed in a protected path such as `/usr/local/bin/s
**OSD update notification did not appear**
`updates.notificationType: "osd"` uses the existing mpv/overlay notification path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` or `"both"` if you want OS notifications outside playback.
`updates.notificationType: "osd"` uses the legacy mpv OSD path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` for OS notifications, `"both"` for overlay + OS notifications, or `"osd-system"` in `config.jsonc` if you want the legacy OSD + OS combination.
## AnkiConnect
@@ -164,7 +164,7 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
**Overlay does not appear**
- Confirm SubMiner is running: `SubMiner.AppImage --start` or check for the process.
- On Linux, the overlay requires a supported window backend. Hyprland and Sway have native Wayland support; all other compositors require both mpv and SubMiner to run under X11 or Xwayland (`xdotool` and `xwininfo` must be installed).
- On Linux, the overlay requires a supported window backend. Hyprland and Sway have native Wayland support; all other compositors require both mpv and SubMiner to run under X11 or Xwayland (`xdotool`, `xprop`, and `xwininfo` must be installed).
- On macOS, grant Accessibility permission to SubMiner in System Settings > Privacy & Security > Accessibility.
**Overlay appears but clicks pass through / cannot interact**
@@ -177,7 +177,7 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
- Renderer errors now trigger an automatic recovery path. You should see a short toast ("Renderer error recovered. Overlay is still running.").
- Recovery closes any open modal and restores click-through/shortcuts automatically without interrupting mpv playback.
- If errors keep recurring, toggle the overlay's DevTools using overlay chord `y` then `d` (or global `F12`) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context.
- If errors keep recurring, toggle the overlay's DevTools using overlay chord `y` then `d` (`F12` also works in dev builds) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context.
**Overlay is on the wrong monitor or position**
@@ -258,22 +258,22 @@ Without FFmpeg, card creation still works but audio and image fields will be emp
Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try:
- Using a local copy of the video file.
- Reducing `media.imageQuality` or switching from `avif` to `static` image type.
- Checking that `media.maxMediaDuration` is not set too high.
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
- Checking that `ankiConnect.media.maxMediaDuration` is not set too high.
## Shortcuts
**"Failed to register global shortcut"**
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
This warning refers to the OS-registered shortcut `Alt+Shift+Y` (Yomitan settings), which is fixed and may conflict with other applications or desktop environment keybindings.
- Check your DE/WM keybinding settings for conflicts.
- Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`.
- Check your DE/WM keybinding settings for conflicts and free up `Alt+Shift+Y` there.
- `Alt+Shift+O` (`shortcuts.toggleVisibleOverlayGlobal`) is not OS-registered - it is handled by the overlay window and the mpv plugin, so it does not trigger this warning and only needs those windows focused.
- On Wayland, global shortcut registration has limitations depending on the compositor. Only Hyprland and Sway are supported natively - see the [Hyprland](#hyprland) section below for shortcut passthrough rules. Other Wayland compositors require X11/Xwayland.
**Overlay keybindings not working**
Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use the global shortcut to toggle it to give it focus.
Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use `Alt+Shift+O` (with the overlay or mpv focused) to toggle it and give it focus.
## Subtitle Timing
@@ -307,9 +307,9 @@ Install ffsubsync or configure the path:
- **pip**: `pip install ffsubsync`
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config.
**"Subtitle synchronization failed"**
**"alass synchronization failed" / "ffsubsync synchronization failed"**
If subtitle sync fails:
If subtitle sync fails (the error message is prefixed with the engine name):
- Ensure the reference subtitle track exists in the video (alass requires a source track).
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
+42 -18
View File
@@ -54,19 +54,26 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings:
Live-updated settings include:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
- `subtitleSidebar`
- `notifications`
- `logging`
- `jimaku`, `subsync`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
- `stats.toggleKey`, `stats.markWatchedKey`
- `youtube.primarySubLanguages`
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
@@ -82,12 +89,13 @@ subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file (overlay auto-starts)
subminer --start video.mkv # Explicit overlay start (use when auto_start=no in config)
subminer -S video.mkv # Same as above via --start-overlay
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
subminer --setup # Open first-run setup popup
subminer --version # Print installed SubMiner version
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
subminer app --setup # Open first-run setup popup
subminer --version # Print the launcher's version
subminer -v # Same as above
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
subminer --log-level warn video.mkv # Set logging level explicitly
@@ -111,11 +119,19 @@ subminer config show # Print active config contents
subminer mpv socket # Print active mpv socket path
subminer mpv status # Exit 0 if socket is ready, else exit 1
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
subminer sync media-box # Sync stats/watch history with an SSH host
subminer sync media-box --push # Merge this machine's stats into the host only
subminer sync media-box --pull # Merge the host's stats into this machine only
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv
subminer texthooker # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser
subminer stats # Start the local stats server (see Immersion Tracking)
subminer stats -b # Start/reuse the background stats daemon
subminer stats -s # Stop the background stats daemon
subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
# Direct packaged app control
@@ -129,9 +145,11 @@ SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open SubMiner settings window
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
@@ -147,7 +165,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
SubMiner.AppImage --help # Show all options
```
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
@@ -163,6 +181,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
Override with e.g. `--password-store=basic_text`.
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug` (or `SubMiner.exe --start --dev --log-level debug` on Windows).
- `--playback-feedback <text>` (also `--playback-feedback=<text>`) sends a non-empty text string through the playback-feedback route used for recording/playback prompts. For example: `SubMiner.AppImage --playback-feedback "your feedback"`.
### Windows mpv Shortcut
@@ -189,9 +208,10 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
- `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
@@ -205,7 +225,7 @@ Setup popup appears on first launch, or when setup has not been completed.
You can also open it manually:
```bash
subminer --setup
subminer app --setup
SubMiner.AppImage --setup
```
@@ -241,6 +261,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
- `--sub-file-paths=.;subs;subtitles`
- `--sid=auto`
- `--secondary-sid=auto`
- `--sub-visibility=no` (the overlay renders subtitles instead of mpv)
- `--secondary-sub-visibility=no`
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
@@ -286,8 +307,8 @@ Notes:
- Press `Ctrl+Alt+C` during active YouTube playback to open the manual YouTube subtitle picker and retry track selection.
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants).
- YouTube auto-selection always targets a Japanese primary track and an English secondary track (manual uploads preferred over auto-generated captions). `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`) defines which loaded track counts as a satisfactory primary for the missing-subtitle notification and for managed local/playlist selection.
- When multiple matching secondary tracks exist, SubMiner prefers a non-Signs/Songs track.
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
@@ -322,6 +343,8 @@ By default SubMiner uses the first connected controller after controller support
| `Select` / `Minus` | Quit mpv |
| `L2` / `R2` | Unbound (available for custom bindings) |
Note: the default quit binding uses gamepad button index 6. Pads that follow the W3C standard gamepad layout report L2 as index 6 (Select is index 8), so on those controllers the quit action may fire on L2 instead - use `Alt+C` learn mode to remap it for your pad.
### Analog Controls
| Input | Action |
@@ -339,18 +362,18 @@ All button and axis mappings are configurable under the `controller` config bloc
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
**Global shortcuts** (work system-wide):
**App-wide shortcuts:**
| Keybind | Action |
| ------------- | ---------------------- |
| `Alt+Shift+O` | Toggle visible overlay |
| `Alt+Shift+Y` | Open Yomitan settings |
| Keybind | Action | Scope |
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
Press `V` to hide or restore the primary SubMiner subtitle bar. The bundled mpv plugin also binds bare `v` to the same action (injected at runtime).
Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible → hover modes. The bundled mpv plugin also binds bare `v` to the same action (injected at runtime).
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
@@ -360,5 +383,6 @@ Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan p
- Drop video files onto the overlay to replace current playback.
- Hold `Shift` while dropping to append to the playlist instead.
- Drop subtitle files onto the overlay to load them as a new subtitle track.
Next: [Mining Workflow](/mining-workflow) - word lookup, card creation, and the full mining loop.
+9 -3
View File
@@ -265,10 +265,16 @@ script-message subminer-options
script-message subminer-restart
script-message subminer-status
script-message subminer-autoplay-ready
script-message subminer-aniskip-refresh
script-message subminer-skip-intro
script-message subminer-stats-toggle
script-message subminer-visible-overlay-shown
script-message subminer-visible-overlay-hidden
script-message subminer-managed-subtitles-loading
script-message subminer-overlay-loading-ready
script-message subminer-reload-session-bindings
```
The overlay/loading/session-binding messages are primarily sent by the SubMiner app to keep the plugin's state in sync. The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) are handled by the SubMiner app over the mpv IPC socket while it is connected.
The start command also accepts inline overrides:
```text
@@ -283,7 +289,7 @@ Examples:
- send `subminer-start` after your own media-selection script chooses a file
- send `subminer-status` before running follow-up automation
- send `subminer-aniskip-refresh` after you update title/episode metadata
- send `subminer-aniskip-refresh` after you update title/episode metadata (handled by the SubMiner app)
#### Build a launcher wrapper
+31 -18
View File
@@ -74,20 +74,30 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
- Select different primary and secondary tracks
- Retry track loading if the auto-load failed or picked the wrong track
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
card to a success notification after the selected tracks load.
The picker displays each track with its language, kind (manual/auto), and title when available.
## Subtitle Format Handling
SubMiner handles several YouTube subtitle formats transparently:
| Format | Handling |
| ------ | -------- |
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
| Format | Handling |
| ---------------------- | -------------------------------------------------------- |
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
## Card Media Cache
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
Background cache downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`; set `0` for unlimited) and use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
## Configuration Reference
### Primary Subtitle Languages
@@ -95,42 +105,45 @@ For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (Ti
```jsonc
{
"youtube": {
"primarySubLanguages": ["ja", "jpn"]
}
"primarySubLanguages": ["ja", "jpn"],
},
}
```
| Option | Type | Description |
| ------ | ---- | ----------- |
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
| Option | Type | Description |
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. |
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked.
### Secondary Subtitle Languages
Secondary track selection uses the shared `secondarySub` config:
YouTube secondary selection is fixed: SubMiner always tries an English track (manual over auto) and loads it when found. The shared `secondarySub` config does not change YouTube track selection — `secondarySubLanguages` and `autoLoadSecondarySub` apply only to local/Jellyfin sidecar selection — but `defaultMode` still controls how the loaded secondary bar is displayed:
```jsonc
{
"secondarySub": {
"secondarySubLanguages": [],
"autoLoadSecondarySub": false,
"defaultMode": "hover"
}
"defaultMode": "hover",
},
}
```
| Option | Type | Description |
| ------ | ---- | ----------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
| Option | Type | Description |
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track for local/Jellyfin sidecar files. Default is empty (`[]`). Not used for YouTube. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary sidecar track for local files (default: `false`). Not used for YouTube. |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
## Limitations and Troubleshooting
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
+5 -2
View File
@@ -61,8 +61,10 @@
committed file — so review it before committing. If you add more
`changes/*.md` fragments for a later beta/RC, rerun
`bun run changelog:prerelease-notes --version <version>`; the generator uses
the existing prerelease notes as the baseline and asks Claude to merge only
the new fragment material. Do not run `bun run changelog:build`.
the existing prerelease notes as the baseline only when their hidden
`prerelease-base-version` marker matches the current base version, and asks
Claude to merge only the new fragment material. Do not run
`bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -77,6 +79,7 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
@@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
#### Subtitle File Parsing
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text.
A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:**
```typescript
+1
View File
@@ -39,6 +39,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Runtime-option contracts: `src/types/runtime-options.ts`
- Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.ts`
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
- Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics
@@ -33,6 +33,10 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
## Contract
Stats data crosses the process boundary over HTTP only. Shared JSON models live in `src/types/stats-wire.ts`; endpoint request/response mappings and the client interface live in `src/types/stats-http-contract.ts`. The server and stats client both type-check against those files. `stats/src/types/stats.ts` is a compatibility re-export, not an independent contract copy.
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
+39 -1
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-01
Last verified: 2026-06-14
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -64,6 +64,35 @@ prefetch work and re-centers prefetch around the live playback time.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written.
## Startup Ready Release
- `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before
releasing the mpv startup gate.
- Visible-overlay startup creates the tray and visible overlay shell before tokenization and
annotation warmups continue. Cold `--start --background --managed-playback` launches still handle
initial args before the deferred Yomitan wait.
- Overlay-routed startup notifications are queued in the main process until an overlay window has
finished loading. Progress notifications with the same id are upserted so spinner ticks do not
flood a cold-start overlay, while events with distinct history ids are retained for phase-level
history such as character dictionary checking/building/importing.
- The mpv plugin has a 30-second fallback for cold starts; app-side retry/release budgets match that
window so readiness can still arrive before fallback resumes playback.
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
waits for a fresh measured subtitle rectangle before signaling readiness.
- If the startup subtitle has no cached annotations yet, autoplay priming emits a raw first-paint
subtitle payload before background tokenization. The tokenized payload replaces it when ready, but
the visible overlay can paint and measure the line before the mpv startup gate resumes playback.
- If startup `sub-text` is temporarily empty, autoplay priming refreshes the active subtitle source
and then awaits cue-based priming before synthetic warm readiness can proceed. A parsed current or
imminent cue is treated as the startup subtitle so the visible overlay can paint and measure it
before playback resumes.
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
a later subtitle event that cannot happen while mpv is paused.
- After a synthetic warm readiness release, SubMiner briefly polls/refreshes the current subtitle
again. This covers Linux/mpv startup cases where `sub-text` is still empty while paused but becomes
available right after playback resumes, without waiting for the next subtitle property change.
## Linux/X11 Window Shape
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
@@ -76,6 +105,15 @@ prefetch work and re-centers prefetch around the live playback time.
overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping.
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
interaction state is already inactive. The next cursor-poll tick must still reapply
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
full-window input capture and block both mpv and overlay controls before the first subtitle
measurement.
- Visible-overlay show also starts a short Linux input grace before the first content measurement.
Native Wayland surfaces can become inert while `setIgnoreMouseEvents(true)` is active; keeping the
overlay interactive during this startup gap lets notifications and overlay mouse bindings work
until subtitle/sidebar/notification rectangles are reported.
## Config And Migration
+15 -1
View File
@@ -3,10 +3,22 @@
# Verification
Status: active
Last verified: 2026-05-23
Last verified: 2026-07-06
Owner: Kyle Yasuda
Read when: selecting the right verification lane for a change
## Lane Infrastructure
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
directory — new test files join their lane automatically; never hand-list test
files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
(per-file isolation with a wall timeout) so a hanging test or leaked global in
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
`--single-process` restores the shared-process mode for debugging.
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
unit, `scripts/**`, and the compiled runtime-compat slice.
## Default Handoff Gate
```bash
@@ -31,6 +43,8 @@ bun run docs:build
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above
+30
View File
@@ -0,0 +1,30 @@
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
// (see the build banner in package.json), but the repo typechecks with plain
// tsc which has no bun type definitions.
declare module 'bun:sqlite' {
export interface RunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
all(...params: ParamsType): ReturnType[];
get(...params: ParamsType): ReturnType | undefined;
run(...params: ParamsType): RunResult;
}
export class Database {
constructor(
filename: string,
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
);
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
sql: string,
): Statement<ReturnType, ParamsType>;
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
sql: string,
): Statement<ReturnType, ParamsType>;
run(sql: string, ...params: unknown[]): RunResult;
close(throwOnError?: boolean): void;
}
}
+31 -4
View File
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
@@ -45,9 +45,8 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
@@ -263,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
await assert.rejects(
async () => {
await runMpvPreAppCommand(context, {
ensureRuntimePluginReady: async () => {},
waitForUnixSocketReady: async () => false,
resolveRuntimePluginPath: () => null,
launchMpvIdleDetached: async () => {},
});
},
@@ -271,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
);
});
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
const context = createContext();
context.args.mpvIdle = true;
const calls: string[] = [];
const handled = await runMpvPostAppCommand(context, {
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
});
test('dictionary command forwards --dictionary and target path to app binary', () => {
const context = createContext();
context.args.dictionary = true;
@@ -362,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
'info:Support assets update: skipped',
]);
});
+228
View File
@@ -0,0 +1,228 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fail, log } from '../log.js';
import { commandExists } from '../util.js';
import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
queryLocalWatchHistory,
resolveImmersionDbPath,
sortVideosByEpisode,
type HistorySeriesEntry,
} from '../history.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
return;
}
if (!commandExists('fzf')) fail('Missing dependency: fzf');
}
function showRofiIndexMenu(
labels: string[],
prompt: string,
themePath: string | null,
icons: Array<string | null> = [],
): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
if (hasIcons) {
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
}
const lines = labels.map((label, index) =>
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
);
const result = spawnSync('rofi', rofiArgs, {
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function showFzfIndexMenu(labels: string[], prompt: string): number {
const lines = labels.map((label, index) => `${index}\t${label}`);
const result = spawnSync(
'fzf',
[
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}: `,
'--delimiter=\t',
'--with-nth=2..',
],
{
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
const picked = (result.stdout || '').trim();
const tab = picked.indexOf('\t');
if (tab === -1) return -1;
const idx = Number.parseInt(picked.slice(0, tab), 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function pickIndex(
labels: string[],
prompt: string,
useRofi: boolean,
themePath: string | null,
icons: Array<string | null> = [],
): number {
if (labels.length === 0) return -1;
return useRofi
? showRofiIndexMenu(labels, prompt, themePath, icons)
: showFzfIndexMenu(labels, prompt);
}
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
const { parsedSeason, parsedEpisode } = entry.lastWatched;
if (parsedEpisode === null) return '';
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
}
function formatSeriesLabel(entry: HistorySeriesEntry): string {
const episodeLabel = formatEpisodeLabel(entry);
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
}
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
const { args, scriptPath } = context;
const videos = sortVideosByEpisode(collectVideos(dir, false));
if (videos.length === 0) {
fail(`No video files found in: ${dir}`);
}
const selected = args.useRofi
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
: showFzfMenu(videos);
return selected || null;
}
function browseEpisodes(
entry: HistorySeriesEntry,
context: LauncherCommandContext,
themePath: string | null,
): string | null {
const { args } = context;
const seasons = listSeasonDirs(entry.seriesRoot);
let dir = entry.seriesRoot;
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} — Season`,
args.useRofi,
themePath,
);
if (idx < 0) return null;
dir = seasons[idx]!.path;
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
dir = seasons[0]!.path;
}
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
const dbPath = resolveImmersionDbPath();
if (!fs.existsSync(dbPath)) {
fail(`Watch history database not found: ${dbPath}`);
}
const rows = queryLocalWatchHistory(dbPath);
const series = groupHistoryBySeries(rows);
if (series.length === 0) {
fail('No local watch history found (or watched directories are not accessible).');
}
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
const coverPaths = args.useRofi
? materializeCoverArt(
dbPath,
series.map((seriesEntry) => seriesEntry.coverBlobHash),
)
: new Map<string, string>();
const seriesIcons = series.map((seriesEntry) =>
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
);
const seriesIdx = pickIndex(
series.map(formatSeriesLabel),
'Watch History',
args.useRofi,
themePath,
seriesIcons,
);
if (seriesIdx < 0) return null;
const entry = series[seriesIdx]!;
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
args.useRofi,
themePath,
actions.map(() => entryIcon),
);
if (actionIdx < 0) return null;
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
}
}
+17 -1
View File
@@ -5,9 +5,12 @@ import {
resolveLauncherRuntimePluginPath,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
interface MpvCommandDeps {
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
launchMpvIdleDetached(
socketPath: string,
appPath: string,
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
}
const defaultDeps: MpvCommandDeps = {
ensureRuntimePluginReady: async (context) => {
await ensureLinuxRuntimePluginAvailable({
appPath: context.appPath ?? undefined,
scriptPath: context.scriptPath,
logLevel: context.args.logLevel,
});
},
waitForUnixSocketReady,
resolveRuntimePluginPath: (context) =>
resolveLauncherRuntimePluginPath({
appPath: context.appPath ?? '',
scriptPath: context.scriptPath,
}),
launchMpvIdleDetached,
};
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await deps.ensureRuntimePluginReady(context);
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
deps.resolveRuntimePluginPath(context),
{
...pluginRuntimeConfig,
backend: args.backend,
+66 -11
View File
@@ -35,6 +35,15 @@ function createContext(): LauncherCommandContext {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
@@ -82,9 +91,8 @@ function createContext(): LauncherCommandContext {
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
@@ -113,6 +121,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -162,6 +171,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -209,9 +219,8 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
@@ -229,6 +238,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -272,15 +282,15 @@ test('plugin auto-start playback attaches a warm background app through the laun
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -341,12 +351,12 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined;
let overlayLoadingOsd: boolean | undefined;
try {
process.env.XDG_CONFIG_HOME = xdgConfigHome;
@@ -354,10 +364,23 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
overlayLoadingOsd = (
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
)?.overlayLoadingOsd;
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
overlayConfigDir = configDir;
@@ -374,6 +397,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
assert.equal(availabilityConfigDir, expectedConfigDir);
assert.equal(overlayConfigDir, expectedConfigDir);
assert.equal(overlayLoadingOsd, true);
} finally {
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
@@ -403,14 +427,14 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -432,3 +456,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['plugin', 'startMpv']);
});
+20
View File
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
import { nowMs } from '../time.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
import {
getDefaultConfigDir,
getSetupStatePath,
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
return runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady,
ensureRuntimePluginReady: async (commandContext) => {
await ensureLinuxRuntimePluginAvailable({
appPath: commandContext.appPath ?? undefined,
scriptPath: commandContext.scriptPath,
logLevel: commandContext.args.logLevel,
});
},
chooseTarget,
checkDependencies,
registerCleanup,
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
type PlaybackCommandDeps = {
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
chooseTarget: (
args: Args,
scriptPath: string,
@@ -232,6 +241,14 @@ export async function runPlaybackCommandWithDeps(
? { ...pluginRuntimeConfig, autoStart: false }
: pluginRuntimeConfig;
const shouldShowOverlayLoadingOsd =
!isAppOwnedYoutubeFlow &&
(pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) &&
(pluginRuntimeConfig.autoStart ||
args.startOverlay ||
args.autoStartOverlay ||
shouldLauncherAttachRunningApp);
const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
@@ -245,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
);
}
await deps.ensureRuntimePluginReady(context);
await deps.startMpv(
selectedTarget.target,
selectedTarget.kind,
@@ -266,6 +285,7 @@ export async function runPlaybackCommandWithDeps(
}
: {}),
backend: args.backend,
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
},
},
+257
View File
@@ -0,0 +1,257 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Args } from '../types.js';
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
import type { LauncherCommandContext } from './context.js';
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
return {
args: {
sync: true,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: 'warn',
...overrides,
} as Args,
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '',
pluginRuntimeConfig: {},
appPath: null,
launcherJellyfinConfig: {},
processAdapter: process,
} as unknown as LauncherCommandContext;
}
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
const deps: Partial<SyncCommandDeps> = {
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
findLiveStatsDaemonPid: () => null,
canConnectUnixSocket: async () => socketConnectable,
fail: (message: string): never => {
throw new Error(message);
},
};
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (dbPath: string, outPath: string) => {
calls.push(`snapshot:${dbPath}->${outPath}`);
},
mergeSnapshotIntoDb: (dbPath: string, snapshotPath: string) => {
calls.push(`merge:${dbPath}<-${snapshotPath}`);
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host: string) => {
calls.push(`host:${host}`);
},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
return command.startsWith('mktemp ') ? ok('/tmp/subminer-sync.remote\n') : ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
},
fail: (message: string): never => {
throw new Error(message);
},
};
assert.equal(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
deps,
);
assert.ok(calls.includes('host:media-box'));
await assert.rejects(
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
});
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
mkdtempSync: ((prefix: string) => {
localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), path.basename(prefix)));
return localTmpDir;
}) as typeof fs.mkdtempSync,
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --snapshot ')) return ok();
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
}
return ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
};
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
assert.ok(calls.includes('local-merge'));
assert.ok(calls.some((call) => call.startsWith('ssh:rm -rf ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --snapshot ')) {
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
}
return ok();
},
runScp: () => {},
};
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
return {
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
return ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
};
}
test('runHostSync push only snapshots locally and merges remotely', async () => {
const calls: string[] = [];
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'push',
}),
makeDirectionDeps(calls),
);
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(!calls.includes('local-merge'));
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'pull',
}),
makeDirectionDeps(calls),
);
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
assert.ok(calls.includes('local-merge'));
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
+253
View File
@@ -0,0 +1,253 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fail, log } from '../log.js';
import { resolveImmersionDbPath } from '../history-db.js';
import {
createDbSnapshot,
findLiveStatsDaemonPid,
formatMergeSummary,
mergeSnapshotIntoDb,
} from '../sync/sync-db.js';
import {
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
shellQuote,
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.js';
export interface SyncCommandDeps {
createDbSnapshot: typeof createDbSnapshot;
mergeSnapshotIntoDb: typeof mergeSnapshotIntoDb;
formatMergeSummary: typeof formatMergeSummary;
findLiveStatsDaemonPid: typeof findLiveStatsDaemonPid;
assertSafeSshHost: typeof assertSafeSshHost;
resolveRemoteSubminerCommand: typeof resolveRemoteSubminerCommand;
runScp: typeof runScp;
runSsh: typeof runSsh;
fail: typeof fail;
log: typeof log;
canConnectUnixSocket: typeof canConnectUnixSocket;
realpathSync: typeof fs.realpathSync;
mkdtempSync: typeof fs.mkdtempSync;
rmSync: typeof fs.rmSync;
consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
}
function resolveDbPath(context: LauncherCommandContext): string {
const override = context.args.syncDbPath.trim();
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
}
function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
const trackerDbPath = resolveImmersionDbPath();
try {
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
export async function ensureTrackerQuiescent(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
deps.fail(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
deps.fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
}
const defaultSyncCommandDeps: SyncCommandDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
formatMergeSummary,
findLiveStatsDaemonPid,
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
fail,
log,
canConnectUnixSocket,
realpathSync: fs.realpathSync,
mkdtempSync: fs.mkdtempSync,
rmSync: fs.rmSync,
consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
return { ...defaultSyncCommandDeps, ...inputDeps };
}
export function runSnapshotMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
const deps = resolveSyncCommandDeps(inputDeps);
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
deps.createDbSnapshot(dbPath, outPath);
deps.consoleLog(outPath);
}
export async function runMergeMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.consoleLog(deps.formatMergeSummary(summary));
}
function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void {
if (!remoteTmpDir.startsWith('/tmp/')) return;
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
}
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
const stderr = run.stderr.trim();
return stderr ? `${message}\n${stderr}` : message;
}
export async function runHostSync(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
const host = args.syncHost;
const direction = args.syncDirection ?? 'both';
const shouldPull = direction !== 'push';
const shouldPush = direction !== 'pull';
deps.assertSafeSshHost(host);
await deps.ensureTrackerQuiescent(context, dbPath);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
let remoteTmpDir = '';
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
// main().catch() reports the message the same way fail() would.
const mktemp = deps.runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
remoteTmpDir = mktemp.stdout.trim();
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
throw new Error(`Could not create a temporary directory on ${host}.`);
}
const forceFlag = args.syncForce ? ' --force' : '';
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.createDbSnapshot(dbPath, localSnapshot);
}
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`);
const snapshotRun = deps.runSsh(
host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
}
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
if (shouldPull) deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
if (shouldPush) deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`);
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
deps.consoleLog(deps.formatMergeSummary(summary));
}
if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`);
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
);
deps.writeStdout(mergeRun.stdout);
if (mergeRun.status !== 0) {
const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
const localUpdate = shouldPull ? ' The local database was updated;' : '';
throw new Error(
formatRemoteRunError(
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" once the remote issue is fixed.`,
mergeRun,
),
);
}
}
deps.consoleLog('\nSync complete.');
} finally {
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteTmpDir, deps);
} catch {
// best effort
}
}
}
}
export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
if (!args.sync) return false;
const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
return true;
}
+7 -3
View File
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
supportAssets: [
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
'info:Support assets (theme) update: updated - Installed theme.',
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
]);
});
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
'info:Support assets update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
+21 -7
View File
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
supportAssets: Array<{
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
}>;
};
type UpdateCommandDeps = {
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
result: {
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
},
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
const componentLabel = result.component ? ` (${result.component})` : '';
const detailSuffix = result.message ? ` - ${result.message}` : '';
deps.log(
'info',
configuredLogLevel,
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
`${label}${componentLabel} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
}
return true;
}
+27 -20
View File
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
autoStartSubMiner: false,
pauseUntilOverlayReady: false,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
@@ -102,8 +100,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
assert.equal(parsed.autoStartSubMiner, false);
assert.equal(parsed.pauseUntilOverlayReady, false);
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
@@ -129,6 +125,11 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig({
auto_start_overlay: false,
ankiConnect: {
behavior: {
notificationType: 'osd-system',
},
},
texthooker: {
launchAtStartup: false,
},
@@ -138,8 +139,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
@@ -148,10 +147,21 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.osdMessages, true);
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parsePluginRuntimeConfigFromMainConfig disables plugin osd messages for overlay notification routing', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig({
ankiConnect: {
behavior: {
notificationType: 'both',
},
},
});
assert.equal(parsed.osdMessages, false);
});
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
@@ -160,9 +170,8 @@ test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.osdMessages, false);
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, true);
assert.equal(parsed.aniskipButtonKey, 'TAB');
});
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
@@ -175,9 +184,8 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
osdMessages: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
'/fallback/SubMiner.AppImage',
),
@@ -187,10 +195,11 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
'subminer-backend=x11',
'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
'subminer-osd_messages=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8',
],
);
});
@@ -205,9 +214,8 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8,\nF9',
},
'/fallback/SubMiner.AppImage',
),
@@ -217,10 +225,11 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
'subminer-backend=x11',
'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
'subminer-osd_messages=no',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8 F9',
],
);
});
@@ -244,8 +253,6 @@ test('parseLauncherMpvConfig reads configured mpv profile', () => {
pauseUntilOverlayReady: undefined,
subminerBinaryPath: undefined,
profile: 'anime',
aniskipEnabled: undefined,
aniskipButtonKey: undefined,
},
);
+36
View File
@@ -135,6 +135,15 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -181,6 +190,15 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -220,6 +238,15 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -257,6 +284,15 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
+21
View File
@@ -198,6 +198,15 @@ export function createDefaultArgs(
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -231,6 +240,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.history === true) parsed.history = true;
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true;
@@ -262,6 +272,17 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
) {
fail('Dictionary target path is required.');
}
if (invocations.syncTriggered) {
parsed.sync = true;
parsed.syncHost = invocations.syncHost ?? '';
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
parsed.syncMergePath = invocations.syncMergePath ?? '';
parsed.syncDirection = invocations.syncDirection;
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
@@ -42,3 +42,22 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
assert.equal(result.invocations.texthookerTriggered, true);
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
test('parseCliPrograms captures one-way sync directions', () => {
const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer');
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
assert.equal(push.invocations.syncDirection, 'push');
assert.equal(pull.invocations.syncDirection, 'pull');
});
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
/--push and --pull cannot be combined/,
);
assert.throws(
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
/--push and --pull require a host/,
);
});
+72
View File
@@ -38,6 +38,15 @@ export interface CliInvocations {
statsCleanupVocab: boolean;
statsCleanupLifetime: boolean;
statsLogLevel: string | null;
syncTriggered: boolean;
syncHost: string | null;
syncSnapshotPath: string | null;
syncMergePath: string | null;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
@@ -64,6 +73,7 @@ function applyRootOptions(program: Command): void {
.option('--settings', 'Open settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
}
@@ -97,6 +107,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'dictionary',
'dict',
'stats',
'sync',
'texthooker',
'app',
'bin',
@@ -159,6 +170,15 @@ export function parseCliPrograms(
let statsCleanupVocab = false;
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncHost: string | null = null;
let syncSnapshotPath: string | null = null;
let syncMergePath: string | null = null;
let syncDirection: 'both' | 'push' | 'pull' = 'both';
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let logsTriggered = false;
@@ -288,6 +308,49 @@ export function parseCliPrograms(
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command('sync')
.description('Sync stats and watch history with another machine over SSH')
.argument('[host]', 'SSH destination (user@host or an ssh config alias)')
.option('--snapshot <file>', 'Write a consistent snapshot of the local stats database')
.option('--merge <file>', 'Merge a snapshot database file into the local stats database')
.option('--push', 'Only merge local stats into the SSH host')
.option('--pull', 'Only merge stats from the SSH host into the local database')
.option('--db <file>', 'Override the local stats database path')
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
.option('-f, --force', 'Skip the running-app safety check')
.option('--log-level <level>', 'Log level')
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
const snapshot = typeof options.snapshot === 'string' ? options.snapshot.trim() : '';
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
const push = options.push === true;
const pull = options.pull === true;
if (push && pull) {
throw new Error('Sync --push and --pull cannot be combined.');
}
if ((push || pull) && !host) {
throw new Error('Sync --push and --pull require a host.');
}
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
}
if (modes > 1) {
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
}
syncTriggered = true;
syncHost = host || null;
syncSnapshotPath = snapshot || null;
syncMergePath = merge || null;
syncDirection = push ? 'push' : pull ? 'pull' : 'both';
syncRemoteCmd =
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command('doctor')
.description('Run dependency and environment checks')
@@ -399,6 +462,15 @@ export function parseCliPrograms(
statsCleanupVocab,
statsCleanupLifetime,
statsLogLevel,
syncTriggered,
syncHost,
syncSnapshotPath,
syncMergePath,
syncDirection,
syncRemoteCmd,
syncDbPath,
syncForce,
syncLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,
-2
View File
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
};
}
+5 -7
View File
@@ -16,10 +16,9 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
function pluginOsdMessagesFromNotificationType(root: Record<string, unknown> | null): boolean {
const notificationType = rootObject(rootObject(root, 'ankiConnect'), 'behavior').notificationType;
return notificationType === 'osd' || notificationType === 'osd-system';
}
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
@@ -53,9 +52,8 @@ export function parsePluginRuntimeConfigFromMainConfig(
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
osdMessages: pluginOsdMessagesFromNotificationType(root),
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
};
}
@@ -72,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
logLevel,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, osd_messages=${parsed.osdMessages}, texthooker_enabled=${parsed.texthookerEnabled}`,
);
return parsed;
}
+126
View File
@@ -0,0 +1,126 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { withReadonlyWalRetry } from './history-db.js';
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
export function getDefaultCoverCacheDir(): string {
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
}
export function detectImageExtension(blob: Buffer): string {
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
return '.png';
}
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
return '.jpg';
}
if (
blob.length >= 12 &&
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
blob.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp';
}
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
return '.gif';
}
return '.jpg';
}
function findCachedCover(cacheDir: string, hash: string): string | null {
for (const ext of COVER_EXTENSIONS) {
const candidate = path.join(cacheDir, `${hash}${ext}`);
try {
if (fs.statSync(candidate).size > 0) return candidate;
} catch {
// not cached with this extension
}
}
return null;
}
function queryCoverBlobs(
dbPath: string,
hashes: string[],
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
): Map<string, Buffer> {
const blobs = new Map<string, Buffer>();
const db = new Database(dbPath, options);
try {
const hasBlobTable = db
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
.get();
if (!hasBlobTable) return blobs;
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
);
for (const hash of hashes) {
const row = stmt.get(hash);
if (row?.cover_blob && row.cover_blob.length > 0) {
blobs.set(hash, Buffer.from(row.cover_blob));
}
}
return blobs;
} finally {
db.close();
}
}
function isSafeCoverHash(hash: string | null | undefined): hash is string {
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
}
/**
* Ensures cover art blobs referenced by hash exist as image files in the cache
* directory, extracting missing ones from the stats database. Returns a map of
* blob hash to on-disk image path for every cover that could be materialized.
*/
export function materializeCoverArt(
dbPath: string,
hashes: Array<string | null | undefined>,
cacheDir: string = getDefaultCoverCacheDir(),
): Map<string, string> {
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
const resolved = new Map<string, string>();
if (wanted.length === 0) return resolved;
const missing: string[] = [];
for (const hash of wanted) {
const cached = findCachedCover(cacheDir, hash);
if (cached) {
resolved.set(hash, cached);
} else {
missing.push(hash);
}
}
if (missing.length === 0) return resolved;
let blobs: Map<string, Buffer>;
try {
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
} catch {
return resolved;
}
if (blobs.size === 0) return resolved;
try {
fs.mkdirSync(cacheDir, { recursive: true });
} catch {
return resolved;
}
for (const [hash, blob] of blobs) {
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
try {
fs.writeFileSync(target, blob);
resolved.set(hash, target);
} catch {
// cache write failure just means no icon for this entry
}
}
return resolved;
}
+151
View File
@@ -0,0 +1,151 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js';
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
const tracking =
root?.immersionTracking &&
typeof root.immersionTracking === 'object' &&
!Array.isArray(root.immersionTracking)
? (root.immersionTracking as Record<string, unknown>)
: null;
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
if (configured) return resolvePathMaybe(configured);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
return path.join(configDir, 'immersion.sqlite');
}
interface RawHistoryRow {
video_id: number;
source_path: string | null;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
anime_title: string | null;
last_watched_ms: number | bigint | null;
cover_blob_hash: string | null;
}
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
}
export function withReadonlyWalRetry<T>(
dbPath: string,
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
): T {
try {
return query({ readonly: true });
} catch (error) {
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
return query({ readwrite: true, create: false });
}
}
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
if (!isWalModeSqliteDatabase(dbPath)) return false;
const code =
typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const message = error instanceof Error ? error.message : String(error);
const text = `${code} ${message}`.toLowerCase();
return (
text.includes('readonly') ||
text.includes('read-only') ||
text.includes('attempt to write a readonly database') ||
text.includes('sqlite_cantopen') ||
text.includes('unable to open database file')
);
}
function isWalModeSqliteDatabase(dbPath: string): boolean {
const header = Buffer.alloc(20);
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
}
function tableExists(db: Database, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
);
}
function readHistoryRows(
dbPath: string,
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
): HistoryVideoRow[] {
const db = new Database(dbPath, options);
try {
const hasMediaArt = tableExists(db, 'imm_media_art');
const coverSelect = hasMediaArt
? `COALESCE(
ma.cover_blob_hash,
(SELECT ma2.cover_blob_hash
FROM imm_media_art ma2
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
LIMIT 1)
) AS cover_blob_hash`
: 'NULL AS cover_blob_hash';
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
const rows = db
.query<RawHistoryRow>(
`
SELECT
v.video_id,
v.source_path,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
${coverSelect}
FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
${coverJoin}
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
GROUP BY v.video_id
ORDER BY last_watched_ms DESC
`,
)
.all();
return rows
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
.map((row) => ({
videoId: row.video_id,
sourcePath: row.source_path!,
parsedTitle: row.parsed_title,
parsedSeason: row.parsed_season,
parsedEpisode: row.parsed_episode,
animeTitle: row.anime_title,
lastWatchedMs: Number(row.last_watched_ms ?? 0),
coverBlobHash: row.cover_blob_hash,
}));
} finally {
db.close();
}
}
+132
View File
@@ -0,0 +1,132 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseMediaInfo } from '../src/jimaku/utils.js';
import { collectVideos } from './picker.js';
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
export function seasonNumberFromDirName(name: string): number | null {
const match = name.trim().match(SEASON_DIR_PATTERN);
if (!match) return null;
const parsed = Number.parseInt(match[1]!, 10);
return Number.isFinite(parsed) ? parsed : null;
}
export function resolveSeriesRoot(filePath: string): string {
const parent = path.dirname(filePath);
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
return path.dirname(parent);
}
return parent;
}
export function groupHistoryBySeries(
rows: HistoryVideoRow[],
existsFn: (candidate: string) => boolean = fs.existsSync,
): HistorySeriesEntry[] {
const byRoot = new Map<string, HistorySeriesEntry>();
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
for (const row of sorted) {
const seriesRoot = resolveSeriesRoot(row.sourcePath);
const existing = byRoot.get(seriesRoot);
if (existing) {
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
existing.coverBlobHash = row.coverBlobHash;
}
continue;
}
if (!existsFn(seriesRoot)) continue;
const displayName =
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
byRoot.set(seriesRoot, {
seriesRoot,
displayName,
lastWatched: row,
coverBlobHash: row.coverBlobHash,
});
}
return Array.from(byRoot.values());
}
function compareNatural(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
export function sortVideosByEpisode(videos: string[]): string[] {
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
parsed.sort((a, b) => {
if (a.info.episode !== null && b.info.episode !== null) {
const seasonA = a.info.season ?? 0;
const seasonB = b.info.season ?? 0;
if (seasonA !== seasonB) return seasonA - seasonB;
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
}
return compareNatural(a.video, b.video);
});
return parsed.map((entry) => entry.video);
}
function dirContainsVideo(dir: string): boolean {
return collectVideos(dir, true).length > 0;
}
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
} catch {
return [];
}
const dirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
path: path.join(seriesRoot, entry.name),
season: seasonNumberFromDirName(entry.name),
}))
.filter((entry) => dirContainsVideo(entry.path));
dirs.sort((a, b) => {
if (a.season !== null && b.season !== null && a.season !== b.season) {
return a.season - b.season;
}
return compareNatural(a.name, b.name);
});
return dirs;
}
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
const seriesRoot = resolveSeriesRoot(resolvedLast);
if (seriesRoot === dir) return null;
const seasons = listSeasonDirs(seriesRoot);
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
return nextSeason[0] ?? null;
}
export function findNextEpisode(lastPath: string): string | null {
const resolvedLast = path.resolve(lastPath);
const dir = path.dirname(resolvedLast);
const episodes = sortVideosByEpisode(collectVideos(dir, false));
const idx = episodes.indexOf(resolvedLast);
if (idx >= 0) {
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
} else {
const lastInfo = parseMediaInfo(resolvedLast);
if (lastInfo.episode !== null) {
const candidate = episodes.find((episode) => {
const info = parseMediaInfo(episode);
return info.episode !== null && info.episode > lastInfo.episode!;
});
if (candidate) return candidate;
}
}
return findFirstEpisodeInNextSeason(resolvedLast, dir);
}
+23
View File
@@ -0,0 +1,23 @@
export interface HistoryVideoRow {
videoId: number;
sourcePath: string;
parsedTitle: string | null;
parsedSeason: number | null;
parsedEpisode: number | null;
animeTitle: string | null;
lastWatchedMs: number;
coverBlobHash: string | null;
}
export interface HistorySeriesEntry {
seriesRoot: string;
displayName: string;
lastWatched: HistoryVideoRow;
coverBlobHash: string | null;
}
export interface SeasonDirEntry {
name: string;
path: string;
season: number | null;
}
+441
View File
@@ -0,0 +1,441 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
materializeCoverArt,
queryLocalWatchHistory,
resolveSeriesRoot,
seasonNumberFromDirName,
sortVideosByEpisode,
type HistoryVideoRow,
} from './history.js';
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
return {
videoId: 1,
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
parsedTitle: 'Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: null,
lastWatchedMs: 1000,
coverBlobHash: null,
...overrides,
};
}
test('seasonNumberFromDirName detects common season directory names', () => {
assert.equal(seasonNumberFromDirName('Season-1'), 1);
assert.equal(seasonNumberFromDirName('Season 2'), 2);
assert.equal(seasonNumberFromDirName('S03'), 3);
assert.equal(seasonNumberFromDirName('season_04'), 4);
assert.equal(seasonNumberFromDirName('Specials'), null);
assert.equal(seasonNumberFromDirName('Show Name'), null);
});
test('resolveSeriesRoot skips season directories', () => {
assert.equal(
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
'/media/anime/Show',
);
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
});
test('groupHistoryBySeries keeps most recent entry per series root', () => {
const rows = [
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
makeRow({
videoId: 2,
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
parsedEpisode: 2,
lastWatchedMs: 3000,
}),
makeRow({
videoId: 3,
sourcePath: '/media/anime/Other/Other - 05.mkv',
parsedTitle: 'Other',
parsedSeason: null,
parsedEpisode: 5,
lastWatchedMs: 2000,
}),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series.length, 2);
assert.equal(series[0]?.displayName, 'Show');
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
assert.equal(series[1]?.displayName, 'Other');
});
test('groupHistoryBySeries filters series roots that no longer exist', () => {
const rows = [
makeRow({ videoId: 1 }),
makeRow({
videoId: 2,
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
parsedTitle: 'Missing',
lastWatchedMs: 5000,
}),
];
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
assert.equal(series.length, 1);
assert.equal(series[0]?.displayName, 'Show');
});
test('groupHistoryBySeries falls back to directory name for display', () => {
const rows = [
makeRow({
sourcePath: '/media/anime/Some Show Dir/video.mkv',
parsedTitle: null,
animeTitle: null,
}),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series[0]?.displayName, 'Some Show Dir');
});
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
const videos = [
'/media/Show/Show - S01E10 - Ten.mkv',
'/media/Show/Show - S01E02 - Two.mkv',
'/media/Show/Show - S01E01 - One.mkv',
];
assert.deepEqual(sortVideosByEpisode(videos), [
'/media/Show/Show - S01E01 - One.mkv',
'/media/Show/Show - S01E02 - Two.mkv',
'/media/Show/Show - S01E10 - Ten.mkv',
]);
});
function createSeriesTree(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
const seriesRoot = path.join(root, 'Show');
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
fs.mkdirSync(season1, { recursive: true });
fs.mkdirSync(season2, { recursive: true });
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
fs.writeFileSync(path.join(season1, name), '');
}
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
return seriesRoot;
}
test('listSeasonDirs returns only video-bearing directories in season order', () => {
const seriesRoot = createSeriesTree();
try {
const seasons = listSeasonDirs(seriesRoot);
assert.deepEqual(
seasons.map((entry) => entry.name),
['Season-1', 'Season-2'],
);
assert.deepEqual(
seasons.map((entry) => entry.season),
[1, 2],
);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode advances within a season and across seasons', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
assert.equal(
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
path.join(season1, 'Show - S01E03.mkv'),
);
assert.equal(
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
path.join(season2, 'Show - S02E01.mkv'),
);
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode falls back to episode numbers when file was removed', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function createHistoryDb(
dbPath: string,
options: { wal?: boolean; coverArt?: boolean } = {},
): void {
const db = new Database(dbPath);
try {
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
db.run(`
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY,
canonical_title TEXT,
title_romaji TEXT
);
`);
db.run(`
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY,
anime_id INTEGER,
source_type INTEGER,
source_path TEXT,
parsed_title TEXT,
parsed_season INTEGER,
parsed_episode INTEGER
);
`);
db.run(`
CREATE TABLE imm_sessions(
session_id INTEGER PRIMARY KEY,
video_id INTEGER,
started_at_ms TEXT
);
`);
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
db.run(`
INSERT INTO imm_videos VALUES
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
`);
db.run(`
INSERT INTO imm_sessions VALUES
(1, 1, '1000'),
(2, 1, '5000'),
(3, 2, '3000'),
(4, 3, '9000');
`);
if (options.coverArt) {
db.run(`
CREATE TABLE imm_media_art(
video_id INTEGER PRIMARY KEY,
cover_blob_hash TEXT
);
`);
db.run(`
CREATE TABLE imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL
);
`);
// Art only on video 1; video 2 resolves it through the shared anime_id.
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
}
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
} finally {
db.close();
}
}
function assertHistoryRows(dbPath: string): void {
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows.length, 2);
assert.equal(rows[0]?.videoId, 1);
assert.equal(rows[0]?.lastWatchedMs, 5000);
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
assert.equal(rows[1]?.videoId, 2);
assert.equal(rows[1]?.lastWatchedMs, 3000);
}
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath);
assertHistoryRows(dbPath);
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows[0]?.coverBlobHash, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath, { coverArt: true });
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows[0]?.videoId, 1);
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
assert.equal(rows[1]?.videoId, 2);
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const cacheDir = path.join(dir, 'covers');
try {
createHistoryDb(dbPath, { coverArt: true });
const covers = materializeCoverArt(
dbPath,
['hash-1', 'hash-1', null, 'hash-missing'],
cacheDir,
);
const coverPath = covers.get('hash-1');
assert.ok(coverPath);
assert.equal(path.extname(coverPath!), '.png');
assert.ok(fs.statSync(coverPath!).size > 0);
assert.equal(covers.has('hash-missing'), false);
// Cached file is reused even when the database has disappeared.
fs.rmSync(dbPath);
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
assert.equal(cachedCovers.get('hash-1'), coverPath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const cacheDir = path.join(dir, 'covers');
const unsafeHash = '../escape';
try {
createHistoryDb(dbPath, { coverArt: true });
const db = new Database(dbPath);
try {
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
} finally {
db.close();
}
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
assert.equal(covers.has(unsafeHash), false);
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('detectImageExtension identifies common cover formats', () => {
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
});
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
const rows = [
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series.length, 1);
assert.equal(series[0]?.lastWatched.videoId, 2);
assert.equal(series[0]?.coverBlobHash, 'hash-1');
});
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath, { wal: true });
// Reproduce the state after the app shuts down cleanly: WAL journal mode
// with no -wal/-shm sidecar files on disk. A read-only connection then
// fails at query time because it cannot recreate them.
fs.rmSync(`${dbPath}-wal`, { force: true });
fs.rmSync(`${dbPath}-shm`, { force: true });
assertHistoryRows(dbPath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
const walDbPath = path.join(dir, 'wal.sqlite');
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
try {
createHistoryDb(walDbPath, { wal: true });
createHistoryDb(rollbackDbPath);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('attempt to write a readonly database'), {
code: 'SQLITE_READONLY',
}),
walDbPath,
),
true,
);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('unable to open database file'), {
code: 'SQLITE_CANTOPEN',
}),
walDbPath,
),
true,
);
assert.equal(
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
false,
);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('attempt to write a readonly database'), {
code: 'SQLITE_READONLY',
}),
rollbackDbPath,
),
false,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+4
View File
@@ -0,0 +1,4 @@
export * from './history-art.js';
export * from './history-db.js';
export * from './history-navigation.js';
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
+143
View File
@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from './types.js';
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
function createArgs(): Args {
return {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: 'https://jellyfin.example.test',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
};
}
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
const calls: string[] = [];
try {
await assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
createArgs(),
'/tmp/subminer',
'/tmp/subminer.sock',
{
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => false,
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveLauncherRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
runAppCommandWithInheritLogged: () => {
calls.push('handoff');
throw new Error('stop after handoff');
},
log: () => {},
},
),
/stop after handoff/,
);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
} finally {
if (originalAccessToken === undefined) {
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
} else {
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
}
if (originalUserId === undefined) {
delete process.env.SUBMINER_JELLYFIN_USER_ID;
} else {
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
}
}
});
+87 -19
View File
@@ -30,9 +30,54 @@ import {
resolveLauncherRuntimePluginPath,
waitForUnixSocketReady,
} from './mpv.js';
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
type JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
findRofiTheme: typeof findRofiTheme;
resolveJellyfinSelection: typeof resolveJellyfinSelection;
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
pathExists: (candidate: string) => boolean;
ensureRuntimePluginReady: (options: {
appPath: string;
scriptPath: string;
logLevel: Args['logLevel'];
}) => Promise<void>;
waitForUnixSocketReady: typeof waitForUnixSocketReady;
launchMpvIdleDetached: typeof launchMpvIdleDetached;
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
log: typeof log;
};
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig,
findRofiTheme,
resolveJellyfinSelection,
hasStoredJellyfinSession,
requestJellyfinPreviewAuthFromApp,
resolveLauncherMainConfigPath,
resolveJellyfinSelectionViaApp,
pathExists: (candidate) => fs.existsSync(candidate),
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
await ensureLinuxRuntimePluginAvailable({
appPath,
scriptPath,
logLevel,
});
},
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
runAppCommandWithInheritLogged,
log,
};
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
scriptPath: string,
mpvSocketPath: string,
): Promise<never> {
const config = loadLauncherJellyfinConfig();
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
}
export async function runJellyfinPlayMenuWithDeps(
appPath: string,
args: Args,
scriptPath: string,
mpvSocketPath: string,
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
): Promise<never> {
const config = deps.loadLauncherJellyfinConfig();
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
const session: JellyfinSessionConfig = {
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '',
};
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
deps.log(
'warn',
args.logLevel,
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
);
}
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = '';
if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
} else {
const configPath = resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) {
const configPath = deps.resolveLauncherMainConfigPath();
if (!deps.hasStoredJellyfinSession(configPath)) {
fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
);
}
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge ready for picker image previews.',
);
} else {
log(
deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
);
}
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
}
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
if (deps.pathExists(mpvSocketPath)) {
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(
await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
deps.log(
'debug',
args.logLevel,
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
);
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}
+3 -2
View File
@@ -2,9 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
import { localDateKey } from '../src/shared/log-files.js';
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
const today = new Date().toISOString().slice(0, 10);
const today = localDateKey(new Date());
const resolved = getDefaultMpvLogFile({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
@@ -20,7 +21,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
});
test('getDefaultLauncherLogFile uses launcher prefix', () => {
const today = new Date().toISOString().slice(0, 10);
const today = localDateKey(new Date());
const resolved = getDefaultLauncherLogFile({
platform: 'linux',
homeDir: '/home/tester',
+46 -16
View File
@@ -65,11 +65,36 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
APPDATA: xdgConfigHome,
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
XDG_CONFIG_HOME: xdgConfigHome,
// Pin the data dir under the temp home so the Linux runtime-plugin preflight
// resolves managed asset paths deterministically (not the CI runner's).
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
PATH: pathValue,
Path: pathValue,
};
}
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
// when the runtime plugin/theme are missing — spawns the app with
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
// (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
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
// 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.
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
mkdir -p "$data/plugin/subminer" "$data/themes"
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
printf 'test=true\\n' > "$data/plugin/subminer.conf"
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
mkdir -p "$(dirname "$3")"
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
fi
exit 0
fi
`;
test('config path uses XDG_CONFIG_HOME override', () => {
withTempDir((root) => {
const xdgConfigHome = path.join(root, 'xdg');
@@ -237,7 +262,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -264,7 +289,7 @@ test('launcher settings option forwards app settings window command', () => {
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -288,7 +313,7 @@ test('launcher settings command forwards app settings window command', () => {
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -369,7 +394,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
},
}),
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(
@@ -460,7 +485,7 @@ test('launcher forwards non-info log level into mpv logging args', { timeout: 15
},
}),
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(
@@ -539,7 +564,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
);
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -566,17 +591,22 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
// Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
// *every* app command — including the Linux runtime-plugin preflight's
// `--ensure-linux-runtime-plugin-assets` install — and returns without
// running the fake app, so the preflight would poll 30s for a response that
// never arrives and time out. This test asserts on the mpv args, not on
// captured app args, so capture isn't needed.
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
DISPLAY: ':99',
XDG_SESSION_TYPE: 'x11',
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
};
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env);
// Pass an explicit backend so overlay startup doesn't probe for a display
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const forwardedArgs = fs
@@ -597,7 +627,7 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -626,7 +656,7 @@ test('dictionary command forwards manual AniList selection modes to app command
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -763,7 +793,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -790,7 +820,7 @@ test('jellyfin discovery via jf alias forwards remote announce for cast visibili
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -817,7 +847,7 @@ test('jellyfin login routes credentials to app command', () => {
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
@@ -856,7 +886,7 @@ test('jellyfin setup forwards password-store to app command', () => {
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
+16
View File
@@ -21,6 +21,8 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runHistoryCommand } from './commands/history-command.js';
import { runSyncCommand } from './commands/sync-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
@@ -106,6 +108,10 @@ async function main(): Promise<void> {
return;
}
if (await runSyncCommand(context)) {
return;
}
const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
@@ -142,6 +148,16 @@ async function main(): Promise<void> {
return;
}
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
}
await runPlaybackCommand(appContext);
}
+10 -35
View File
@@ -23,8 +23,6 @@ import {
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadataForLaunch,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
state,
@@ -387,32 +385,14 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
assert.equal(
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'TAB',
}),
false,
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
@@ -565,20 +545,6 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
}
});
test('shouldResolveAniSkipMetadata skips URL and YouTube-preloaded playback', () => {
assert.equal(shouldResolveAniSkipMetadata('/media/show.mkv', 'file'), true);
assert.equal(
shouldResolveAniSkipMetadata('https://www.youtube.com/watch?v=test123', 'url'),
false,
);
assert.equal(
shouldResolveAniSkipMetadata('/tmp/video123.webm', 'file', {
primaryPath: '/tmp/video123.ja.srt',
}),
false,
);
});
function makeArgs(overrides: Partial<Args> = {}): Args {
return {
backend: 'x11',
@@ -604,6 +570,15 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: 'error',
logRotation: 7,
passwordStore: '',
+4 -51
View File
@@ -27,7 +27,7 @@ import {
shouldForwardLogLevel,
} from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildSubminerScriptOpts } from './script-opts.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
import { nowMs } from './time.js';
import {
@@ -823,20 +823,6 @@ export async function loadSubtitleIntoMpv(
}
}
export function shouldResolveAniSkipMetadata(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
): boolean {
if (targetKind !== 'file') {
return false;
}
if (preloadedSubtitles?.primaryPath || preloadedSubtitles?.secondaryPath) {
return false;
}
return !isYoutubeTarget(target);
}
type StartMpvOptions = {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
@@ -844,18 +830,6 @@ type StartMpvOptions = {
runtimePluginConfig?: PluginRuntimeConfig;
};
export function shouldResolveAniSkipMetadataForLaunch(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
runtimePluginConfig?: PluginRuntimeConfig,
): boolean {
if (runtimePluginConfig?.aniskipEnabled === false) {
return false;
}
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
}
export function buildRuntimeExtraScriptOptParts(
target: string,
targetKind: 'file' | 'url',
@@ -946,29 +920,14 @@ export async function startMpv(
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
target,
targetKind,
preloadedSubtitles,
options?.runtimePluginConfig,
)
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
...runtimeScriptOpts,
...extraScriptOpts,
]);
if (aniSkipMetadata) {
log(
'debug',
args.logLevel,
`AniSkip metadata (${aniSkipMetadata.source}): title="${aniSkipMetadata.title}" season=${aniSkipMetadata.season ?? '-'} episode=${aniSkipMetadata.episode ?? '-'}`,
);
}
mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
@@ -1701,13 +1660,7 @@ export function launchMpvIdleDetached(
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts(
appPath,
socketPath,
null,
args.logLevel,
runtimeScriptOpts,
)}`,
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`);
@@ -1730,7 +1683,7 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
export async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
+13
View File
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
assert.equal(parsed.settings, true);
});
test('parseArgs maps root watch history flags', () => {
const shortParsed = parseArgs(['-H'], 'subminer', {});
const longParsed = parseArgs(['--history'], 'subminer', {});
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
const defaultParsed = parseArgs([], 'subminer', {});
assert.equal(shortParsed.history, true);
assert.equal(longParsed.history, true);
assert.equal(rofiParsed.history, true);
assert.equal(rofiParsed.useRofi, true);
assert.equal(defaultParsed.history, false);
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
+2 -2
View File
@@ -56,7 +56,7 @@ export function showFzfFlatMenu(
`--prompt=${prompt}`,
'--delimiter=\t',
'--with-nth=2',
'--preview-window=right:50%:wrap',
'--preview-window=down:50%:wrap',
'--preview',
previewCommand,
];
@@ -468,7 +468,7 @@ thumb=$(get_thumb)
'--prompt=Select Video: ',
'--delimiter=\t',
'--with-nth=1',
'--preview-window=right:50%:wrap',
'--preview-window=down:50%:wrap',
'--preview',
previewCmd,
],
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
ensureLinuxRuntimePluginAvailable,
installManagedPluginAssetsViaApp,
} from './runtime-plugin-preflight';
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'darwin',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {
calls.push('log');
},
});
assert.deepEqual(calls, []);
});
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => {
calls.push('detect');
return true;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
isManagedThemeAvailable: () => {
calls.push('theme');
return false;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'detect',
'resolve',
'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve',
]);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
const calls: string[] = [];
let resolveCount = 0;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => {
resolveCount += 1;
calls.push(`resolve:${resolveCount}`);
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'resolve:1',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve:2',
]);
});
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: false,
status: 'failed',
error: 'copy failed',
}),
log: () => {},
}),
/copy failed/,
);
});
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
}),
/managed runtime plugin assets could not be installed/i,
);
});
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
let waited = false;
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 1,
stdout: '',
stderr: '',
error: new Error('spawn failed'),
}),
waitForInstallResponse: async () => {
waited = true;
return null;
},
},
);
assert.deepEqual(result, {
ok: false,
status: 'failed',
error: 'spawn failed',
});
assert.equal(waited, false);
});
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
const originalRmSync = fs.rmSync;
fs.rmSync = ((targetPath, options) => {
if (String(targetPath).includes('subminer-runtime-plugin-')) {
throw new Error('cleanup failed');
}
return originalRmSync(targetPath, options);
}) as typeof fs.rmSync;
try {
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 0,
stdout: '',
stderr: '',
}),
waitForInstallResponse: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
},
);
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
});
} finally {
fs.rmSync = originalRmSync;
}
});
+223
View File
@@ -0,0 +1,223 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { log as launcherLog } from './log.js';
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
import { nowMs } from './time.js';
import { sleep } from './util.js';
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
import {
resolveManagedLinuxRuntimePluginPaths,
type EnsureLinuxRuntimePluginAssetsResult,
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
const RESPONSE_TIMEOUT_MS = 30_000;
type PreflightLog = (
level: 'debug' | 'info' | 'warn' | 'error',
configured: 'debug' | 'info' | 'warn' | 'error',
message: string,
) => void;
type EnsureLinuxRuntimePluginAvailableOptions = {
appPath?: string;
scriptPath?: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
xdgDataHome?: string;
appDataDir?: string;
detectInstalledPlugin?: () => boolean;
resolveRuntimePluginPath?: () => string | null;
isManagedThemeAvailable?: () => boolean;
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
log?: PreflightLog;
};
type RuntimePluginPreflightResponse = {
ok: boolean;
status: 'installed' | 'already-present' | 'failed';
path?: string;
error?: string;
};
function resolveConfiguredLogLevel(
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
): 'debug' | 'info' | 'warn' | 'error' {
return logLevel ?? 'warn';
}
async function waitForInstallResponse(
responsePath: string,
): Promise<RuntimePluginPreflightResponse | null> {
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return null;
}
type InstallManagedPluginAssetsViaAppDeps = {
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
waitForInstallResponse?: typeof waitForInstallResponse;
};
export async function installManagedPluginAssetsViaApp(
options: {
appPath: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
},
deps: InstallManagedPluginAssetsViaAppDeps = {},
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
const responsePath = path.join(tempDir, 'response.json');
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
try {
const appArgs = [
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
responsePath,
];
const result = runAppCommand(options.appPath, appArgs);
if (result.error) {
return {
ok: false,
status: 'failed',
error: result.error.message,
};
}
if (result.status !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Linux runtime plugin asset install command exited with status ${result.status}.`,
};
}
const response = await waitForResponse(responsePath);
if (response) {
return response;
}
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
};
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Avoid hiding the install failure or success result behind temp cleanup errors.
}
}
}
export async function ensureLinuxRuntimePluginAvailable(
options: EnsureLinuxRuntimePluginAvailableOptions,
): Promise<void> {
const platform = options.platform ?? process.platform;
if (platform !== 'linux') {
return;
}
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
const log = options.log ?? launcherLog;
const homeDir = options.homeDir ?? os.homedir();
const detectInstalledPlugin =
options.detectInstalledPlugin ??
(() =>
detectInstalledMpvPlugin({
platform,
homeDir,
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
}).installed);
const installedPluginAvailable = detectInstalledPlugin();
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
homeDir,
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const resolveRuntimePluginPath =
options.resolveRuntimePluginPath ??
(() => {
if (!options.appPath) return null;
return resolveLauncherRuntimePluginPath({
appPath: options.appPath,
scriptPath: options.scriptPath,
platform,
homeDir,
env: process.env,
});
});
const isManagedThemeAvailable =
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
if (runtimePluginAvailable && isManagedThemeAvailable()) {
return;
}
log(
'info',
configuredLogLevel,
'Linux runtime support assets missing; installing managed plugin/theme assets.',
);
const installManagedPluginAssets =
options.installManagedPluginAssets ??
(() => {
if (!options.appPath) {
throw new Error(
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
);
}
return installManagedPluginAssetsViaApp({
appPath: options.appPath,
logLevel: options.logLevel,
});
});
const installResult = await installManagedPluginAssets();
if (!installResult.ok) {
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
log(
'warn',
configuredLogLevel,
`Managed Linux runtime support asset install failed: ${message}`,
);
throw new Error(message);
}
log(
'info',
configuredLogLevel,
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
);
const runtimePluginPath = resolveRuntimePluginPath();
if (runtimePluginPath) {
return;
}
const message =
`Linux managed runtime plugin assets could not be installed. ` +
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
'Launch aborted before starting mpv.';
log('warn', configuredLogLevel, message);
throw new Error(message);
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildSubminerScriptOpts } from './script-opts';
test('buildSubminerScriptOpts preserves app and socket paths verbatim', () => {
const scriptOpts = buildSubminerScriptOpts(
'/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner',
'/tmp/subminer socket.sock',
['subminer-backend=x11'],
);
assert.equal(
scriptOpts,
'subminer-binary_path=/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner,subminer-socket_path=/tmp/subminer socket.sock,subminer-backend=x11',
);
});
test('buildSubminerScriptOpts rejects delimiter-bearing default paths', () => {
assert.throws(
() => buildSubminerScriptOpts('/tmp/SubMiner,canary', '/tmp/subminer.sock'),
/subminer-binary_path contains unsupported script option delimiter/,
);
assert.throws(
() => buildSubminerScriptOpts('/tmp/SubMiner', '/tmp/subminer\nsocket.sock'),
/subminer-socket_path contains unsupported script option delimiter/,
);
});
+34
View File
@@ -0,0 +1,34 @@
function sanitizeScriptOptValue(value: string): string {
return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assertScriptOptPathValue(name: string, value: string): void {
if (/[,\r\n]/.test(value)) {
throw new Error(`${name} contains unsupported script option delimiter`);
}
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
if (!hasBinaryPath) {
assertScriptOptPathValue('subminer-binary_path', appPath);
}
if (!hasSocketPath) {
assertScriptOptPathValue('subminer-socket_path', socketPath);
}
const parts = [
...(hasBinaryPath ? [] : [`subminer-binary_path=${appPath}`]),
...(hasSocketPath ? [] : [`subminer-socket_path=${socketPath}`]),
...extraParts.map(sanitizeScriptOptValue),
];
return parts.join(',');
}

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