Compare commits

..
65 Commits
Author SHA1 Message Date
sudacode 2aedb3a1fa build(hachidori): bump fork to upstream 0.3.1 (0a00a38) 2026-10-12 22:37:58 -07:00
sudacode 8809ed38b2 Merge remote-tracking branch 'origin/main' into add-hachidori-backend
# Conflicts:
#	src/main.ts
2026-10-12 22:14:30 -07:00
sudacode c18644392d fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread (#289) 2026-10-11 01:02:02 -07:00
sudacode 9c351a8603 fix(overlay): poll cheap playback state in the open subtitle sidebar (#290) 2026-10-11 01:02:02 -07:00
sudacode 6c295586cb fix(hachidori): close an open pane after its attention is released 2026-10-11 00:54:01 -07:00
sudacode 0523beef2b fix(hachidori): treat only an open pane as an open popup for lookup controls 2026-10-11 00:44:07 -07:00
sudacode 06fc578830 Merge remote-tracking branch 'origin/main' into add-hachidori-backend
# Conflicts:
#	src/renderer/handlers/keyboard.test.ts
2026-10-11 00:35:19 -07:00
sudacode 994483ccd6 build(hachidori): bump fork to a52694c (LF checkouts on Windows) 2026-10-10 22:39:29 -07:00
sudacode 6350ee928d build(hachidori): bump fork to 15babde
- Update vendor/hachidori submodule from 343e861 to 15babde
- Remove stale generated release/release-notes.md from v0.20.1
2026-10-10 21:21:46 -07:00
sudacode 4d1adebf11 refactor(hachidori): drop the Docker management upload for character dictionaries
Every supported host now takes dictionary ZIPs over the link, so remove
the port 8780 upload, its hachidori.externalHostManagementUrl override and
the hachidori config section. A linked host without linked-import-v1 now
reports the update or manual-import fix.
2026-10-05 09:50:52 -07:00
sudacode e1c4d6ca94 feat(hachidori): send character dictionaries over the link to Hachidori hosts
Hachidori 0.2.3 accepts dictionary ZIPs as chunked upload requests: an
unlinked install imports them itself and a linked one forwards them to its
host. The parser bridge now imports through that path with replace, retrying
a busy commit with the same upload. Linked hosts without linked-import-v1
(hachidori-docker, older Hachidori) keep the management API upload.
2026-10-05 09:10:33 -07:00
sudacode e25d472d82 build(hachidori): bump fork to upstream 0.2.3 (991c48c) 2026-10-05 09:03:39 -07:00
sudacode 20f151482a Merge remote-tracking branch 'origin/main' into add-hachidori-backend
# Conflicts:
#	launcher/main.test.ts
#	src/cli/help.test.ts
#	src/core/services/__tests__/stats-server.test.ts
#	src/core/services/cli-command.test.ts
#	src/core/services/tokenizer/yomitan-parser-runtime.test.ts
#	src/main/overlay-runtime.test.ts
#	src/main/runtime/character-dictionary-auto-sync.test.ts
#	src/main/runtime/cli-command-context-deps.test.ts
#	src/main/runtime/cli-command-context-factory.test.ts
#	src/main/runtime/cli-command-context-main-deps.test.ts
#	src/main/runtime/composers/cli-startup-composer.test.ts
#	src/main/runtime/stats-server-runtime.test.ts
#	src/main/runtime/tray-main-actions.test.ts
#	src/main/runtime/tray-main-deps.test.ts
#	src/main/runtime/tray-runtime-handlers.test.ts
#	src/main/runtime/tray-runtime.test.ts
#	src/main/runtime/yomitan-anki-server-sync.test.ts
#	src/preload.ts
#	src/renderer/handlers/mouse.test.ts
#	src/renderer/yomitan-popup.test.ts
2026-10-05 09:01:11 -07:00
sudacode 170dbbb74c fix(overlay): treat Hachidori popup host as a lookup target for keys
- Match the Hachidori shadow-root host alongside the Yomitan popup host when routing keyboard events
- Update the keyboard test stub to use HACHIDORI_HOST_SELECTOR
2026-10-04 00:59:04 -07:00
sudacode 37d3f5e03e build(hachidori): bump fork to upstream 9e1dfa3 2026-10-04 00:22:41 -07:00
sudacode 55c751830e build(hachidori): bump fork to upstream 0.2.2 (2a063cd) 2026-10-04 00:04:31 -07:00
sudacode 3091d7b2a7 fix(overlay): forward imported mpv input while Hachidori only claims attention
Hachidori's popup-shown event also fires for any press that may start a
selection, so treating it as an open popup blocked imported mpv mouse
buttons and routed every key to the popup keybind handler. Once Hachidori
is the reader, forwarding, popup key routing and the controller lookup
state now require an unhidden popup pane. Wheel events over an in-document
dictionary popup stay with the popup.

Bump vendor/hachidori so presses outside a text box no longer start a
glyph drag that prevented the press.
2026-10-01 23:56:14 -07:00
sudacode 4423dd56af Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-10-01 21:48:59 -07:00
sudacode a6df21bdc2 feat(overlay): forward mpv mouse and wheel bindings through overlay
- Accept WHEEL_UP/DOWN/LEFT/RIGHT (with modifiers) in keybindings, the plugin, and the settings key editor
- Forward mpv's MBTN_* and WHEEL_* bindings from the overlay so double-click fullscreen, wheel volume, and similar inputs work on Hyprland; SubMiner bindings and right-click pause still win
- Notify the overlay via mpv-input-bindings:changed when mpv's key set changes, fixing dead imported keys when the overlay loads before mpv connects
- Sync README requirements/quick start with the installation guide and fix the Arch MeCab install command (AUR mecab-git)
2026-10-01 01:16:44 -07:00
sudacode e5a6e3b178 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-30 20:52:27 -07:00
sudacode 152d2c0626 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-30 19:50:36 -07:00
sudacode bbb0e9b6a5 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-30 01:37:19 -07:00
sudacode e09a416422 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-29 23:00:07 -07:00
sudacode 42f86b0748 perf(subtitle-prefetch): sweep overlap spans instead of rescanning cues
- Track active cues with a single sorted start/end sweep across boundaries
- Join simultaneous cues in cue-list order to match mpv
- Skip empty or inverted cues
- Add test for multi-cue overlap spans with unsorted cue input
2026-09-29 22:17:10 -07:00
sudacode e69cd5ea06 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-29 20:31:17 -07:00
sudacode 0cff52f8e4 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-29 20:27:55 -07:00
sudacode b52692d826 feat(hachidori): derive Docker management URL from the linked host
- Default character dictionary uploads to the linked host on port 8780; externalHostManagementUrl is now an optional override
- Warn when the override points at a different machine than the link
- Browser/app hosts report where to import the merged ZIP manually
- Name the management origin when the upload request cannot connect
- Update config descriptions, generated examples, docs, and changelog fragment
2026-09-29 20:22:07 -07:00
sudacode 4ab5e37bc5 build(hachidori): bump fork to 32e5346 2026-09-29 19:03:07 -07:00
sudacode f00b830054 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-29 18:49:49 -07:00
sudacode ec73157f37 build(deps): revert fast-uri override to 3.1.7
- Pin the fast-uri override in package.json and bun.lock back to 3.1.7 from 3.1.8
2026-09-28 22:37:29 -07:00
sudacode 45212c7659 build(hachidori): bump fork to upstream 6faca95
Upstream dropped Firefox, so the overlay copy now turns off custom JavaScript
from its plain capability flag. The Anki audio fixture follows the removal of
media capture options.
2026-09-28 21:42:37 -07:00
sudacode b710822db2 build(deps): bump fast-uri override to 3.1.8 for audit advisories 2026-09-28 21:18:38 -07:00
sudacode b347fdf21a feat(youtube): add YouTube browser window and Whisper subtitle source
- Add `subminer youtube`/`yt` and tray "Browse YouTube" window that plays or queues picked videos in mpv
- Add `youtube.subtitleSource` (`youtube` | `whisper`) to transcribe YouTube audio via subtitle generation
- Allow the subtitle generation modal on YouTube videos
- Pick the real Japanese track over machine-translated ones and fall back to yt-dlp on HTTP 429
- Remove legacy launcher youtubeSubgen/whisper/AI args and `--youtube-mode`
- Fix EPIPE crash when the launching terminal is closed with Ctrl+C
- Thin media timing review handles with orange original-boundary bars
- Update docs and change fragments
2026-09-28 20:46:11 -07:00
sudacode a4325740b7 fix(subtitle-prefetch): reuse startup runs and prefetch overlap lines
- Warm combined text for overlapping subtitle cues.
- Preserve valid in-flight tokenization across startup restarts while rejecting invalidated cache results.
2026-09-28 01:17:59 -07:00
sudacode 4648c5e5a1 chore(hachidori): update submodule to upstream 1a12326
Merge 8 upstream Hachidori commits into the subminer fork, including the
Google Docs experimental lookup, monochrome image palette colours and Anki
deferred-audio fixes.
2026-09-25 23:02:23 -07:00
sudacode 72d0b19d94 Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-24 21:33:27 -07:00
sudacode c627095e6e Merge remote-tracking branch 'origin/main' into add-hachidori-backend 2026-09-24 20:43:02 -07:00
sudacode dea1136170 Merge remote-tracking branch 'origin/main' into add-hachidori-backend
# Conflicts:
#	docs-site/anki-integration.md
#	docs-site/immersion-tracking.md
#	docs-site/shortcuts.md
#	docs-site/usage.md
2026-09-24 17:26:30 -07:00
sudacode 874d2c9f1a fix(hachidori): preserve setup completion while host connects
- Reopen setup only when a reachable host has no dictionaries
2026-09-24 14:02:27 -07:00
sudacode 52fc1b63e9 fix(launcher): validate login shell paths before execution
- Fall back to absolute default shells when configured values are relative
- Reject invalid shells before spawning them
2026-09-24 01:31:27 -07:00
sudacode da5074761f fix(launcher): load macOS login shell PATH for GUI launches 2026-09-24 01:10:15 -07:00
sudacode f7230db90c chore(release): prepare v0.20.0
- Build v0.20.0 changelog and release notes from changes/ fragments
- Remove package-size JSON reports and previous-release size comparisons from CI
- Download AUR release assets via curl with retries and skip publish on failure
- Update release docs, verification lanes, and workflow tests to match
2026-09-24 00:01:03 -07:00
sudacode 92ef305e8c chore(hachidori): update submodule to fork subminer 441971b
Pull in the fork fix that looks up popup definition text only when the
pointer is on a glyph, so hovering beside a short glossary row no longer
opens a nested popup for the next row's word.
2026-09-23 22:59:10 -07:00
sudacode 504faa51e0 fix(anki): honor overlay pause that races timing review setup
- Track overlay pauses per review request so a pause arriving while setup reads mpv's pause state still cancels the playback restore
- Ignore cancelPlaybackResume when no review request is in flight
- Add a test covering the pause-read race and reset between requests
2026-09-23 20:08:01 -07:00
sudacode c10174f9cd fix(dictionary): pin Hachidori extension ID with a static manifest key
Electron derives an unpacked extension's ID from its directory, so a changed
userData path gave Hachidori a new storage origin and an empty library. The
staged copy now carries a fixed key, like the bundled Yomitan.
2026-09-23 09:15:33 -07:00
sudacode 33e694bc61 fix(anki): keep playback paused after overlay pause during timing review
- Treat overlay pause requests during an open media timing review as final: drop held resumes and the review's own playback restore
- Add cancelPlaybackResume to the media timing review runtime
- Test both paused and playing starting states
2026-09-23 01:36:11 -07:00
sudacode 430120cc63 fix(anki): hold playback paused while media timing review is open
- Defer overlay resume requests (popup closed, hover left) during an active review and apply them when the review closes
- Add deferPlaybackResume to the media timing review runtime and gate sendRendererMpvCommand on it
- Document the paused-playback behavior and add a changelog fragment
2026-09-23 00:32:35 -07:00
sudacode 294e3a777e chore(hachidori): update submodule to upstream d68089e
Merge 104 upstream Hachidori commits into the subminer fork, including
rebuilt engine artifacts and upstream glyph hover hit-testing.
2026-09-22 22:42:05 -07:00
sudacode c673b71053 fix(dictionary): restore Anki proxy marker when Hachidori sync fails
- Revert the proxy marker to its previous value when every settings write attempt fails, so it no longer claims a server that was never written
- Add a test for the all-writes-fail path
- Retry Windows managed runtime cleanup in the test while bun.exe stays locked after exit
2026-09-22 22:08:51 -07:00
sudacode d31a28c54f fix(dictionary): handle Hachidori extension load failures gracefully
- Catch errors from the Hachidori extension runtime and log them
- Clear the extension and session state and return null instead of rejecting
2026-09-22 21:54:49 -07:00
sudacode d100789232 fix(anki): regenerate sentence furigana when mined context changes
- Regenerate SentenceFurigana from the final sentence after timing-review expansion or stats-dashboard word mining
- Add a Yomitan parseText-based generator with a 10s timeout and escaped, highlighted output
- Clear stale furigana on failure so templates fall back to Sentence
- Keep existing furigana formatting when the sentence is unchanged
- Document the behavior in the anki-integration and immersion-tracking docs
2026-09-22 21:46:06 -07:00
sudacode 9a5fb02cbd fix(dictionary): sync Hachidori deck to ankiConnect.deck for polling
- Always set the first Hachidori Anki template deck to ankiConnect.deck so polling mode enriches Hachidori cards; other custom template settings stay intact
- Treat right-clicks retargeted to the Hachidori popup shadow host as overlay interactions so they no longer raise mpv or toggle pause
- Update anki-integration docs and changelog fragment
2026-09-22 21:43:53 -07:00
sudacode 2ba820f41a fix(dictionary): keep Hachidori lookup highlight on in SubMiner builds
Hachidori's overlay-mode first-install options turn off "Highlight the
word on the page" because GSM-style hosts screenshot the see-through
viewport. SubMiner captures media from mpv, and without the highlight
the subtitle sidebar gave no sign of which word was looked up or where
the match ended. The staged copy now seeds the highlight on; the fork
source is unchanged.
2026-09-22 21:06:19 -07:00
sudacode c39dbb5a8d fix(overlay): keep Hachidori attention from pausing playback
Hachidori's hachidori-popup-shown/hidden pair means "the reader needs
mouse events", which includes a left press anywhere on the overlay that
may start a selection. SubMiner treated it as popup visibility, so a
click paused and resumed mpv, and holding the button kept it paused.
The host element only exists after the first lookup, so the first
attempt (gate on an open popup pane when a host exists) still paused
on clicks before any lookup.

Once a Hachidori event has been seen, popup auto-pause now requires an
unhidden popup pane in the host's shadow root, and rechecks after each
successful lookup so drag-select popups still pause.
2026-09-22 21:06:19 -07:00
sudacode 2958591ec9 fix(dictionary): open Hachidori hover popups only over subtitle glyphs
Pin the Hachidori fork fix that stops the caret snapping into the line box
from looking up a subtitle's first word when the pointer is beside the line,
which kept the popup open and playback paused.
2026-09-22 20:39:35 -07:00
sudacode c9880efee3 test(anilist): apply sqlite pragmas in cover art fetcher tests
- Call applyPragmas before ensureSchema so test databases match the production setup
2026-09-22 19:30:31 -07:00
sudacode f225ac3c89 fix(dictionary): block --yomitan when an external profile is configured
- Keep read-only external-profile mode while Hachidori is active by checking the configured externalProfilePath
- Hachidori settings remain available
- Smoke check asserts the external-profile block, with a --bundled-yomitan path for the bundled case
- Update usage docs and changelog fragment
2026-09-22 19:03:39 -07:00
sudacode cacaa2b371 feat(overlay): add subtitle selection modal and Jellyfin 12 fixes
- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling
- Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info
- Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher
- Bump electron-builder to 26.16.1
- Condense and reconcile changelog fragments; update config example and docs
2026-09-22 18:51:59 -07:00
sudacode 9fe4c7eb33 fix(anki): preserve managed proxy state when settings sync fails 2026-09-22 15:49:24 -07:00
sudacode ab5e50adac fix(dictionary): complete Hachidori imports and mining integration 2026-09-22 15:01:23 -07:00
sudacode 15c74ef502 fix(dictionary): update Hachidori overlay sharing and test fixes 2026-09-22 14:26:32 -07:00
sudacode f0d3c95b4c fix(dictionary): pin Hachidori stats mining fix 2026-09-22 14:04:09 -07:00
sudacode d7e1d7edc6 refactor: configure Hachidori in the SubMiner build 2026-09-22 12:26:37 -07:00
sudacode 5354fdf7b9 refactor(dictionary): use Hachidori fork submodule and upstream API 2026-09-22 01:22:14 -07:00
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00
70 changed files with 209 additions and 951 deletions

No files matched your search

-4
View File
@@ -78,10 +78,6 @@ jobs:
- name: End-to-end suite
run: bun run test:e2e
- name: Dictionary backend windows (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a bun run test:dictionary:electron
- name: Upload session logs (on failure)
if: failure()
uses: actions/upload-artifact@v4
-45
View File
@@ -1,50 +1,5 @@
# Changelog
## v0.21.0 (2026-10-24)
### Added
- **Hachidori Dictionary Backend**:
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
### Fixed
- **Jellyfin**:
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
### Docs
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
<details>
<summary>Internal changes</summary>
### Internal
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
- **Dead Code Removal**:
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
- Typecheck now runs with `noUnusedLocals`.
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
</details>
## v0.20.1 (2026-10-03)
### Added
+1 -1
View File
@@ -201,7 +201,7 @@ The AppImage is all you need. First-run setup can install the optional `subminer
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility** (named **Device Control and Data Access** on macOS 27) so the overlay can track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized. If macOS blocks the first launch, right-click the app and choose **Open**.
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility**, or the overlay cannot follow the mpv window. If macOS blocks the first launch, right-click the app and choose **Open**.
</details>
+6 -7
View File
@@ -19,12 +19,12 @@
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "43.8.3",
"electron": "43.7.7",
"electron-builder": "26.16.1",
"esbuild": "^0.25.12",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.30.1",
"undici": "7.29.1",
},
},
},
@@ -37,7 +37,6 @@
"brace-expansion": "5.0.11",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.7",
"follow-redirects": "1.16.1",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -46,7 +45,7 @@
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.30.1",
"undici": "7.29.1",
},
"packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -305,7 +304,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@43.8.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-KgvSm3bHXz96hSXzqxyRjLeli80rIdszbDQFKlRSfckmGazTPCH/CxUN31Dp9c5rFKTQSNf9gPJRWl1yWNB3pA=="],
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
@@ -351,7 +350,7 @@
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
@@ -617,7 +616,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="],
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: testing
- Added a headless end-to-end harness (`bun run test:e2e`, `bun run e2e`) that boots the built app with a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect on an offscreen display.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Media timing review now keeps playback paused while it is open when the dictionary popup or subtitle hover pause ends, as with Hachidori popups closing when the review appears. Playback resumes after the review closes unless a dictionary popup is still open.
+14
View File
@@ -0,0 +1,14 @@
type: added
area: dictionary
- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner.
- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` opens Yomitan settings unless a read-only external Yomitan profile is configured. This restriction also applies with Hachidori active, without blocking Hachidori settings.
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Character dictionaries reach a linked host over the link (Hachidori 0.2.3 or later, or a current Docker host), retry busy imports, and replace the previous dictionary only after a successful import. Older hosts report where to import the dictionary manually.
- Hachidori auto-populates its first Anki template from SubMiner's deck, tags, and field mappings, detects an unambiguous matching note type, and preserves existing custom templates apart from the deck, which follows `ankiConnect.deck` so polling mode enriches Hachidori cards. Anki discovery retries after an unavailable connection.
- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay.
- Both bundled dictionary backends reload their background code on startup so extension updates take effect while preserving installed dictionaries and settings. Failed Yomitan connection-setting updates can be retried without manually resetting the managed Anki endpoint.
- First-run setup remembers each backend that finished it, including when reopened for legacy plugin cleanup, so switching back does not repeat setup, and the launcher gates playback on the backend the running app started with. Current incomplete or cancelled setup takes precedence over stale completion history. A running Yomitan session keeps using its external profile until it restarts into Hachidori.
- Stats dashboard mining and deck lookup use the selected backend, with the Anki proxy enabled or disabled. Dashboard cards retain the selected history line and media instead of being processed again with the current mpv subtitle, with both Yomitan and Hachidori. Stats cards carry a `SubMiner::Stats` tag so polling also preserves their context. Hachidori word mining supplies dictionary aliases, IDs, and frequency metadata to its native Anki renderer. Settings labels for popup pause and the dictionary deck no longer name Yomitan, and the backend selector sits with the other dictionary settings.
- Hachidori scans, dictionary counts, and settings reads wait for the dictionary engine to finish loading or importing instead of caching empty results.
- First-run setup can link an external Hachidori dictionary host in an app, browser, or Docker container, verify its library, and unlink back to local dictionaries. The optional host controls are collapsed by default and explain which apps or containers must stay running. Unresponsive connection checks time out so setup remains usable. Anki mining, media enrichment, and custom toolbar buttons stay in SubMiner, including when editing buttons while linked.
- Hachidori frequency highlighting reuses dictionary-entry ranks and fills missing ranks through its existing API. Entries without a matching definition may remain unranked. The bundled integration is maintained in a pinned fork submodule using upstream HoshiDicts and WASM binaries.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: dictionary
- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: jellyfin
- Fixed `subminer jellyfin -p` reporting "No Jellyfin libraries found." when SubMiner was already running (for example right after `subminer jellyfin` login). The app now hands library, item, and preview-auth results to the launcher directly instead of through log files, so picker image previews work again and real errors (expired session, unreachable server) are shown instead of an empty list.
- The rofi "Jellyfin Search (optional)" step after picking a library is now a compact input box with a "Type to search, or press Enter to browse all" hint instead of a blank list.
- Playing from the terminal (`subminer jellyfin -p`) now reports watch progress to Jellyfin even when cast discovery is off, so Jellyfin remembers where you stopped and marks finished episodes as played. Previously progress was only sent while the cast connection was active.
- When `subminer jellyfin -p` has to start SubMiner itself, the app now shows its tray icon and quits when you close mpv, the same as playing a local file. Before, it had no tray icon and kept running hidden after playback. A SubMiner that was already running is still reused and left running.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: jellyfin
- Corrected the Jellyfin docs: SubMiner only joins the cast menu at startup when `startupWarmups.jellyfinRemoteSession` is on (off by default) along with `jellyfin.remoteControlAutoConnect`. Otherwise tick **Jellyfin Discovery** in the tray.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Keyboard-only mode no longer swallows `h`, `l`, and the left/right arrows while a modal is open or a text field has focus, so typing into fields like the Jimaku or TsukiHime title works and no longer seeks mpv. Pressing `Ctrl/Cmd+Shift+Y` (or the controller toggle) inside a modal no longer turns on a hidden keyboard-only mode in the modal window.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- Fixed the command-line launcher showing "Not on PATH" on macOS when SubMiner was opened from Finder or the Dock, even though the launcher directory was already on your terminal PATH.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: annotations
- Subtitle lines shown while two cues overlap (common in Jimaku and Jellyfin SRTs, and usually the long lines) are now prefetched, so their annotations appear immediately instead of after a delay.
- Subtitle prefetching no longer restarts each time the same subtitle file is re-selected during startup, and a line tokenized before a restart or seek is kept instead of discarded.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: launcher
- Removed `SUBMINER_JIMAKU_API_KEY`, `SUBMINER_JIMAKU_API_KEY_COMMAND`, and `SUBMINER_JIMAKU_API_BASE_URL` from the development docs' environment variable table. The launcher never used them; set the Jimaku key with `jimaku.apiKey` / `jimaku.apiKeyCommand` in the config instead.
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: cleanup
- Removed dead code: the unused launcher Jimaku client (the `SUBMINER_JIMAKU_*` env vars it read were documented but had no effect), the mpv plugin's hover-highlight module (no sender since Overlay 2.0; custom mpv scripts that still send `subminer-hover-token` or `yomipv-hover-token` are now ignored), the never-read `anilist.characterDictionary.refreshTtlHours` / `evictionPolicy` config keys, unused root dependencies (eslint, geist fonts), and a committed draft plan doc.
- Typecheck now runs with `noUnusedLocals`; removed the unused locals, imports, and the Windows overlay focus-handoff values they fed, which were computed but never used.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: overlay
- Removed 16 overlay preload/IPC methods that nothing called, plus their main-process handlers. SubMiner no longer observes mpv's `sub-text/ass` or sends every ASS line to overlay windows that never listened.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- First-run setup now labels a missing command-line launcher "Not set up" instead of "Not installed", so the AppImage catalog test no longer reads the setup window as an error screen.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up.
- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll.
+2 -2
View File
@@ -741,10 +741,10 @@
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ==========================================
"mpv": {
"executablePath": "", // Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
+1 -1
View File
@@ -49,7 +49,7 @@ Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to Anki
With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings:
- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck.
- While the template still has Hachidori's default `hachidori` tag, the configured tags are added ahead of it so cards carry both. Tags you edit in Hachidori settings are kept as written.
- Configured tags go into untouched defaults.
- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set.
- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields.
+3 -53
View File
@@ -1,56 +1,6 @@
# Changelog
## v0.21.0 (2026-10-24)
**Added**
- **Hachidori Dictionary Backend**:
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
**Fixed**
- **Jellyfin**:
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
**Docs**
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
<details>
<summary>Internal changes</summary>
**Internal**
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
- **Dead Code Removal**:
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
- Typecheck now runs with `noUnusedLocals`.
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
</details>
## Previous Versions
<details>
<summary>v0.20.x</summary>
<h2>v0.20.1 (2026-10-03)</h2>
## v0.20.1 (2026-10-03)
**Added**
- **YouTube Browser Window**:
@@ -100,7 +50,7 @@
</details>
<h2>v0.20.0 (2026-09-23)</h2>
## v0.20.0 (2026-09-23)
**Added**
- **Japanese Subtitle Generation**:
@@ -221,7 +171,7 @@
</details>
</details>
## Previous Versions
<details>
<summary>v0.19.x</summary>
+12 -12
View File
@@ -585,18 +585,18 @@ A local web dashboard at `http://127.0.0.1:<serverPort>`, also available as an o
Settings for mpv instances that SubMiner starts, and for the bundled mpv plugin. See [mpv plugin](/mpv-plugin).
| Key | Default | What it does |
| ---------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------- |
| `mpv.executablePath` | `""` | Full path to mpv. Empty auto-discovers it ([lookup order](/jellyfin-integration#casting-from-jellyfin)) |
| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
| Key | Default | What it does |
| ---------------------------- | ----------------- | --------------------------------------------------------------------------- |
| `mpv.executablePath` | `""` | Path to `mpv.exe` on Windows. Empty checks `SUBMINER_MPV_PATH`, then `PATH` |
| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
### YouTube playback settings
+1 -1
View File
@@ -187,7 +187,7 @@ First-run setup can install the `subminer` command for you.
xattr -d com.apple.quarantine /Applications/SubMiner.app
```
3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). On macOS 27 this list is named **Device Control and Data Access**. SubMiner uses it to track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized.
3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). The overlay cannot follow the mpv window without this.
First-run setup can install the `subminer` command for you.
+1 -3
View File
@@ -23,9 +23,7 @@ SubMiner starts mpv if it is not already running. Pause, seek, stop, and track c
SubMiner selects a Japanese subtitle track automatically and resets mpv's subtitle delay to zero. It direct-plays files when it can and asks Jellyfin to transcode the rest.
If an episode has no Japanese subtitles, you can [generate them with Whisper](/subtitle-generation#generating-from-the-overlay) while it plays.
Casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. On macOS it then checks `/opt/homebrew/bin/mpv`, `/usr/local/bin/mpv`, and `/Applications/mpv.app`, because an app opened from Finder, the Dock, or Spotlight does not get your shell's `PATH`. If mpv lives somewhere else, set `mpv.executablePath` to its full path (`command -v mpv` prints it). An invalid `mpv.executablePath` stops mpv from starting.
On Windows, casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid `mpv.executablePath` stops mpv from starting.
## Playing from the terminal
+2 -2
View File
@@ -741,10 +741,10 @@
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ==========================================
"mpv": {
"executablePath": "", // Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
+2 -4
View File
@@ -16,7 +16,7 @@ The modal's **Local tools** section lists anything missing. After you install a
## Generating from the overlay
1. Open a local video in mpv and select its Japanese audio track, or play a Jellyfin or YouTube video.
1. Open a local video in mpv and select its Japanese audio track, or play a YouTube video.
2. Press `Ctrl+Shift+G`. If the subtitle sidebar is empty, its **Generate Japanese subtitles** button opens the same modal.
3. Pick a model and download it if needed.
4. Optionally check **Focus on spoken dialogue** (see below).
@@ -26,8 +26,6 @@ The modal shows progress. **Cancel** stops the job. Closing the modal lets the j
SubMiner saves `<video>.ja.generated.srt` next to the video and adds a number if that name is taken. If the same file is still playing, it loads the subtitles and resets the subtitle delay.
For a Jellyfin video, SubMiner reads the original file from your server and saves the subtitles to `~/.cache/subminer/generated-subs/`, since a stream has no folder of its own. Each Jellyfin generation replaces the previous file there. Playback keeps going while it works. If Jellyfin is transcoding, SubMiner transcribes the audio track that playback started with.
For a YouTube video, SubMiner downloads its audio first and pauses the video until the subtitles load. The subtitles are temporary, and the audio is deleted when generation ends or you switch videos. See [YouTube integration](/youtube-integration#generate-subtitles-with-whisper).
Change the shortcut with `shortcuts.openSubtitleGeneration`.
@@ -48,7 +46,7 @@ subminer generate-subs episode.mkv --model-path /path/to/ggml-small.bin
| `--audio-stream <index>` | Pick an audio stream by its absolute FFmpeg index |
| `--output <path>` | Write to this SRT path. Existing files are never overwritten. |
With a file argument, SubMiner uses the audio stream tagged Japanese, or the first stream. `Ctrl+C` cancels. The launcher only works with local files. Use the overlay for Jellyfin and YouTube.
With a file argument, SubMiner uses the audio stream tagged Japanese, or the first stream. `Ctrl+C` cancels.
## Choosing a model
+2 -9
View File
@@ -39,18 +39,11 @@ SubMiner reads subtitles from mpv over an IPC socket (a named pipe on Windows).
SubMiner reconnects on its own if mpv restarts.
## Casting from Jellyfin does nothing
When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMiner could not start mpv, so the socket never appeared. Look for `Failed handling Jellyfin remote Play event` or `Failed to launch mpv for Jellyfin remote playback` just before it.
- Check that `mpv --version` works in a terminal.
- If it does, SubMiner is not finding mpv where it is installed. Set `mpv.executablePath` to the output of `command -v mpv`. See [Casting from Jellyfin](/jellyfin-integration#casting-from-jellyfin) for where SubMiner looks.
## Overlay does not appear
- Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process).
- Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors).
- macOS: grant Accessibility permission. See [macOS](#macos).
- macOS: grant Accessibility permission in System Settings > Privacy & Security > Accessibility.
## Overlay is on the wrong monitor or position
@@ -262,5 +255,5 @@ mpv --gpu-context=x11vk,x11egl,x11 video.mkv
## macOS
- Grant Accessibility permission under System Settings > Privacy & Security > Accessibility (named **Device Control and Data Access** on macOS 27). Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized.
- Accessibility permission is required for window tracking: System Settings > Privacy & Security > Accessibility.
- If Gatekeeper blocks the app, right-click it and choose Open, or run `xattr -d com.apple.quarantine /path/to/SubMiner.app`.
+1 -1
View File
@@ -24,7 +24,7 @@ The bundled Yomitan is separate from any Yomitan in your browser. It has its own
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over.
Hachidori uses SubMiner's subtitle scanning, popup pause, controller commands, character dictionaries, and Anki media. Keep the [Anki proxy](/anki-integration#proxy-mode-setup-yomitan-texthooker) on for screenshots and sentence audio. Hachidori's own screen recorder and screenshot capture are off inside SubMiner. `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to whichever backend is selected.
+1 -1
View File
@@ -32,7 +32,7 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (`toJellyfinOriginalStreamUrl`) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
+1 -1
View File
@@ -66,7 +66,7 @@ bun run docs:build
Content checks run inside the electron-builder afterPack hook. See the
[release guide](../RELEASING.md#package-contents-checks) for the
installed-app verification checklist.
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux (CI runs it in the Linux E2E job). It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux. It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above
+4 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.21.0",
"version": "0.20.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -93,7 +93,6 @@
"brace-expansion": "5.0.11",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.7",
"follow-redirects": "1.16.1",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -102,7 +101,7 @@
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.30.1"
"undici": "7.29.1"
},
"keywords": [
"anki",
@@ -131,12 +130,12 @@
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "43.8.3",
"electron": "43.7.7",
"electron-builder": "26.16.1",
"esbuild": "^0.25.12",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.30.1"
"undici": "7.29.1"
},
"build": {
"appId": "com.sudacode.SubMiner",
-60
View File
@@ -1,60 +0,0 @@
## Highlights
### Added
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
### Fixed
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
- **Jellyfin Terminal Playback**:
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
- Watch progress and played status reach Jellyfin even when cast discovery is off.
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
- The rofi search step is now a compact input box.
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
### Docs
- **Hachidori Setup**: Covers backend selection, Hachidori dictionary and Anki setup, settings flags and shortcuts, and linking an external host.
- **Jellyfin**: The docs now say SubMiner joins the cast menu at startup only when both `startupWarmups.jellyfinRemoteSession` and `jellyfin.remoteControlAutoConnect` are on. Otherwise, enable **Jellyfin Discovery** in the tray.
- **Casting and mpv**: Added how casting finds mpv on each platform, a troubleshooting entry for casts that never start mpv, and a note on the macOS 27 Accessibility list rename.
- **Jimaku Environment Variables**: Removed the unused `SUBMINER_JIMAKU_*` variables from the docs. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand` instead.
## What's Changed
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
+1 -1
View File
@@ -141,7 +141,7 @@ app
);
assert.equal(anki.model, 'Custom Japanese');
assert.equal(anki.deck, 'Test Mining');
assert.deepEqual(anki.tags, [...ankiConfig.tags, 'hachidori']);
assert.deepEqual(anki.tags, ankiConfig.tags);
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
@@ -554,7 +554,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'string',
defaultValue: defaultConfig.mpv.executablePath,
description:
'Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.',
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
},
{
path: 'mpv.launchMode',
+1 -1
View File
@@ -223,7 +223,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
'Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.',
'Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
],
key: 'mpv',
},
+1 -1
View File
@@ -335,7 +335,7 @@ function humanizePath(path: string): string {
}
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
if (path === 'dictionaryBackend') {
if (path === 'dictionaryBackend' || path.startsWith('hachidori.')) {
return { category: 'integrations', section: 'Dictionary Lookup' };
}
if (
@@ -1935,27 +1935,6 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card widens halfwidth katakana from older stats lines', async () => {
await withMiningEnv({}, async (env) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
ankiConnectConfig: ankiConfig(env, { fields: { sentence: 'Sentence' } }),
});
const { status, body } = await mineCard(app, 'word', {
sourcePath: env.sourcePath,
sentence: 'アンタがアータルたん?',
word: 'アンタ',
});
assert.equal(status, 200, JSON.stringify(body));
assert.equal(
sentNote(env.requests, 'updateNoteFields').fields.Sentence,
'<b>アンタ</b>がアータルたん?',
);
});
});
it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
await withMiningEnv({}, async (env) => {
const app = createStatsApp(createMockTracker(), {
@@ -3936,83 +3936,6 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
}
});
test('getWordOccurrences widens halfwidth katakana in lines recorded before ingest widening', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
canonicalTitle: 'Episode 8',
sourcePath: '/tmp/KamiKatsu S01E08.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
const wordResult = db
.prepare(
`INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run('アンタ', 'アンタ', 'あんた', 'noun', '名詞', '代名詞', '', 1_000, 1_000, 1);
const lineResult = db
.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
VALUES (?, ?, ?)`,
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
const rows = getWordOccurrences(db, 'アンタ', 'アンタ', 'あんた', 10);
assert.deepEqual(
rows.map((row) => row.text),
['アンタがアータルたん?'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('searchSubtitleSentences matches halfwidth and fullwidth katakana in either direction', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
canonicalTitle: 'Episode 8',
sourcePath: '/tmp/KamiKatsu S01E08.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
const insertLine = db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
insertLine.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
insertLine.run(sessionId, null, videoId, null, 2, 1000, 2000, 'アンタ、ダキニ', 2_000, 2_000);
for (const query of ['アンタ', 'アンタ']) {
assert.deepEqual(
searchSubtitleSentences(db, query, 10).map((row) => row.text),
['アンタ、ダキニ', 'アンタがアータルたん?'],
);
}
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -18,7 +18,6 @@ import type {
} from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time';
import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
@@ -383,13 +382,6 @@ export function getKanjiStats(db: DatabaseSync, limit = 100): KanjiStatsRow[] {
return stmt.all(limit) as KanjiStatsRow[];
}
// Lines recorded before subtitle text was widened at ingest can still hold halfwidth
// katakana; widen them on the way out so the dashboard and cards mined from it match
// newer rows. Display-only: word identity columns stay as stored since they are keys.
function widenLineText<T extends { text: string }>(row: T): T {
return { ...row, text: widenHalfwidthKana(row.text) };
}
export function getWordOccurrences(
db: DatabaseSync,
headword: string,
@@ -398,7 +390,7 @@ export function getWordOccurrences(
limit = 100,
offset = 0,
): WordOccurrenceRow[] {
const rows = db
return db
.prepare(
`
SELECT
@@ -426,7 +418,6 @@ export function getWordOccurrences(
`,
)
.all(headword, word, reading, limit, offset) as unknown as WordOccurrenceRow[];
return rows.map(widenLineText);
}
export function getKanjiOccurrences(
@@ -435,7 +426,7 @@ export function getKanjiOccurrences(
limit = 100,
offset = 0,
): KanjiOccurrenceRow[] {
const rows = db
return db
.prepare(
`
SELECT
@@ -463,7 +454,6 @@ export function getKanjiOccurrences(
`,
)
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
return rows.map(widenLineText);
}
export function searchSubtitleSentences(
@@ -480,14 +470,6 @@ export function searchSubtitleSentences(
const params: string[] = [];
for (const term of terms) {
const likeTerm = `%${escapeLikeTerm(term)}%`;
// Lines recorded before ingest widening can hold halfwidth katakana, so subtitle
// text is matched against the term's widened and narrowed forms as well.
const widenedTerm = widenHalfwidthKana(term);
const textLikeTerms = uniqueNonEmptyTerms([
term,
widenedTerm,
narrowFullwidthKana(widenedTerm),
]).map((variant) => `%${escapeLikeTerm(variant)}%`);
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
const headwordClause =
headwords.length > 0
@@ -503,16 +485,16 @@ export function searchSubtitleSentences(
: '';
clauses.push(`
(
${textLikeTerms.map(() => "l.text LIKE ? ESCAPE '\\'").join(' OR ')}
l.text LIKE ? ESCAPE '\\'
OR v.canonical_title LIKE ? ESCAPE '\\'
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
${headwordClause}
)
`);
params.push(...textLikeTerms, likeTerm, likeTerm, ...headwords);
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
}
const rows = db
return db
.prepare(
`
SELECT
@@ -536,7 +518,6 @@ export function searchSubtitleSentences(
`,
)
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
return rows.map(widenLineText);
}
export function getSessionEvents(
-1
View File
@@ -130,7 +130,6 @@ export {
listSubtitleTracks as listJellyfinSubtitleTracksRuntime,
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
toJellyfinOriginalStreamUrl,
} from './jellyfin';
export { JellyfinRemoteSessionService } from './jellyfin-remote';
export {
-24
View File
@@ -7,7 +7,6 @@ import {
listSubtitleTracks,
resolvePlaybackPlan,
ticksToSeconds,
toJellyfinOriginalStreamUrl,
} from './jellyfin';
const clientInfo = {
@@ -863,26 +862,3 @@ test('resolvePlaybackPlan replaces a legacy api_key on the server transcoding ur
globalThis.fetch = originalFetch;
}
});
test('toJellyfinOriginalStreamUrl maps direct and transcoded playback to the original file', () => {
assert.equal(
toJellyfinOriginalStreamUrl(
'https://media.example/jf/videos/item-1/master.m3u8?ApiKey=token&MediaSourceId=src&VideoCodec=h264&StartTimeTicks=9',
'item-1',
),
'https://media.example/jf/Videos/item-1/stream?static=true&ApiKey=token&MediaSourceId=src',
);
assert.equal(
toJellyfinOriginalStreamUrl(
'http://jf.local:8096/Videos/item-1/stream?static=true&api_key=legacy&AudioStreamIndex=2',
'item-1',
),
'http://jf.local:8096/Videos/item-1/stream?static=true&ApiKey=legacy',
);
assert.equal(
toJellyfinOriginalStreamUrl('https://jf/Videos/other/stream?ApiKey=token', 'item-1'),
null,
);
assert.equal(toJellyfinOriginalStreamUrl('https://jf/Videos/item-1/stream', 'item-1'), null);
assert.equal(toJellyfinOriginalStreamUrl('/media/item-1.mkv', 'item-1'), null);
});
-23
View File
@@ -254,29 +254,6 @@ function createDirectPlayUrl(
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
}
/**
* Maps a playing stream URL of `itemId`, direct or transcoded, to its original file. Returns null
* when the URL is not that item's stream or carries no access token.
*/
export function toJellyfinOriginalStreamUrl(playbackUrl: string, itemId: string): string | null {
let url: URL;
try {
url = new URL(playbackUrl);
} catch {
return null;
}
const at = url.pathname.toLowerCase().indexOf(`/videos/${itemId.toLowerCase()}/`);
if (!/^https?:$/.test(url.protocol) || !itemId || at < 0) return null;
const param = (pattern: RegExp) =>
[...url.searchParams].find(([key, value]) => pattern.test(key) && value)?.[1];
const accessToken = param(/^api_?key$/i);
if (!accessToken) return null;
const query = new URLSearchParams({ static: 'true', ApiKey: accessToken });
const mediaSourceId = param(/^mediasourceid$/i);
if (mediaSourceId) query.set('MediaSourceId', mediaSourceId);
return `${url.origin}${url.pathname.slice(0, at)}/Videos/${itemId}/stream?${query.toString()}`;
}
function createTranscodeUrl(
session: JellyfinAuthSession,
itemId: string,
-12
View File
@@ -131,18 +131,6 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-text', data: 'アンタがアータルたん?' },
deps,
);
assert.equal(state.subText, 'アンタがアータルたん?');
assert.deepEqual(state.events, [{ text: 'アンタがアータルたん?', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload),
+2 -3
View File
@@ -1,6 +1,5 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
export type MpvMessage = {
event?: string;
@@ -245,7 +244,7 @@ export async function dispatchMpvProtocolMessage(
): Promise<void> {
if (msg.event === 'property-change') {
if (msg.name === 'sub-text') {
const nextSubText = widenHalfwidthKana((msg.data as string) || '');
const nextSubText = (msg.data as string) || '';
const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({
text: nextSubText,
@@ -459,7 +458,7 @@ export async function dispatchMpvProtocolMessage(
}>,
);
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
const nextSubText = widenHalfwidthKana((msg.data as string) || '');
const nextSubText = (msg.data as string) || '';
deps.setCurrentSubText(nextSubText);
deps.emitSubtitleChange({
text: nextSubText,
@@ -8,7 +8,6 @@ import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import { STATS_MINING_TAG } from '../../../shared/anki-source.js';
import { widenHalfwidthKana } from '../../utils/halfwidth-kana.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
@@ -37,11 +36,8 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
// Older stats rows can carry halfwidth katakana; cards get the fullwidth form that
// live mining produces, and the word is widened alongside so highlighting still hits.
const sentence =
typeof body?.sentence === 'string' ? widenHalfwidthKana(body.sentence.trim()) : '';
const word = typeof body?.word === 'string' ? widenHalfwidthKana(body.word.trim()) : '';
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
const word = typeof body?.word === 'string' ? body.word.trim() : '';
const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
@@ -55,36 +55,6 @@ test('parseSrtCues strips HTML-like markup while preserving line breaks', () =>
assert.equal(cues[0]!.text, 'これは\nテストです');
});
test('parseSubtitleCues widens halfwidth katakana in cue text', () => {
const cues = parseSubtitleCues(
'1\n00:00:01,000 --> 00:00:02,000\n(トーカ)ダキニお姉ちゃん\n',
'ep.srt',
);
assert.equal(cues[0]?.text, '(トーカ)ダキニお姉ちゃん');
});
test('parseSubtitleCues merges cues that differ only in halfwidth katakana', () => {
const cues = parseSubtitleCues(
[
'1',
'00:00:01,000 --> 00:00:02,000',
'山ガク',
'',
'2',
'00:00:01,000 --> 00:00:02,000',
'山ガク',
'',
].join('\n'),
'ep.srt',
);
assert.deepEqual(
cues.map((cue) => cue.text),
['山ガク'],
);
});
test('parseSrtCues handles hours in timestamps', () => {
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
+2 -9
View File
@@ -9,7 +9,6 @@ import {
type AssOverrideCommand,
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
/** Vertical third of the screen a cue is authored to occupy. */
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
@@ -105,7 +104,7 @@ function decodeSubtitleCueText(text: string): string {
}
function sanitizeSubtitleCueText(text: string): string {
return widenHalfwidthKana(decodeSubtitleCueText(text)).trim();
return decodeSubtitleCueText(text).trim();
}
function sanitizeAssCueText(text: string): string {
@@ -2656,16 +2655,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
placement.playResY,
);
const cues = mergeAssCaptionRows(
return mergeAssCaptionRows(
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
placement.playResY,
);
// Widened after reconstruction, which works glyph by glyph, but before duplicate
// merging, so ガク and ガク authored for the same span collapse into one cue.
for (const cue of cues) {
cue.text = widenHalfwidthKana(cue.text);
}
return cues;
}
export function parseAssCues(content: string): SubtitleCue[] {
@@ -3,11 +3,6 @@ import { expandSubtitleGenerationPath } from './subtitle-generation-files';
const OUTPUT_LIMIT = 64 * 1024;
// ffmpeg echoes input URLs in its errors; streamed media carries access tokens in the query.
function redactUrlCredentials(text: string): string {
return text.replace(/([?&](?:api_?key|token|access_token)=)[^&#\s'"]+/gi, '$1<redacted>');
}
// Keep partial lines between chunks: ffmpeg and whisper both report progress on stderr.
export function runSubtitleGenerationProcess(input: {
command: string;
@@ -68,11 +63,7 @@ export function runSubtitleGenerationProcess(input: {
cleanup();
if (input.signal?.aborted) reject(new Error('Subtitle generation cancelled.'));
else if (code !== 0) {
reject(
new Error(
`${input.command} exited with status ${code}: ${redactUrlCredentials(stderr.trim())}`,
),
);
reject(new Error(`${input.command} exited with status ${code}: ${stderr.trim()}`));
} else resolve(stdout);
});
});
+1 -27
View File
@@ -378,22 +378,7 @@ test('empty executable paths find tools on PATH and explicit overrides take prec
}
}));
test('generation reads an HTTP stream as-is and saves to the requested output', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const mediaPath = 'https://jellyfin.example/Videos/item/stream?static=true&ApiKey=secret';
const outputPath = path.join(directory, 'out', 'stream.ja.srt');
await mkdir(path.dirname(outputPath));
assert.equal(await generateJapaneseSubtitles({ ...input, mediaPath, outputPath }), outputPath);
const calls: string[][] = (await readFile(input.callsPath, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line));
assert.equal(calls[0]?.at(-1), mediaPath);
assert.equal(calls[1]?.[calls[1].indexOf('-i') + 1], mediaPath);
}));
test('generation rejects remote media without an output path, missing models, and missing tools before starting a subprocess', () =>
test('generation rejects remote media, missing models, and missing tools before starting a subprocess', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
await assert.rejects(
@@ -488,17 +473,6 @@ test('process cancellation terminates work and bounds diagnostic output', () =>
error.message.length < 66000 &&
error.message.includes('status 7'),
);
const unauthorized = await executable(
directory,
'unauthorized',
"process.stderr.write('https://host/Videos/1/stream?ApiKey=se+cr/et==&static=true: Server returned 401');process.exitCode=1;",
);
await assert.rejects(
runSubtitleGenerationProcess({ command: unauthorized, args: [] }),
(error: unknown) =>
error instanceof Error &&
error.message.includes('?ApiKey=<redacted>&static=true: Server returned 401'),
);
}));
test('download uses the pinned model revision and removes files that fail integrity', () =>
+3 -6
View File
@@ -191,7 +191,6 @@ export async function requireSubtitleGenerationSetup(
export async function generateJapaneseSubtitles(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
/** A local file, or an http(s) stream when `outputPath` is set. */
mediaPath: string;
audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
@@ -202,12 +201,10 @@ export async function generateJapaneseSubtitles(input: {
signal?: AbortSignal;
}): Promise<string> {
input.signal?.throwIfAborted();
// HTTP streams are read by ffmpeg directly but have no directory to save beside.
const remote = /^https?:\/\//i.test(input.mediaPath);
if (remote ? !input.outputPath : /^[a-z][a-z\d+.-]*:\/\//i.test(input.mediaPath))
if (/^[a-z][a-z\d+.-]*:\/\//i.test(input.mediaPath))
throw new Error('Subtitle generation requires a local media file.');
const mediaPath = remote ? input.mediaPath : path.resolve(input.mediaPath);
if (!remote && !(await stat(mediaPath)).isFile())
const mediaPath = path.resolve(input.mediaPath);
if (!(await stat(mediaPath)).isFile())
throw new Error('Subtitle generation requires a local media file.');
if (input.outputPath) await ensureAvailableOutput(path.resolve(input.outputPath));
await ensureWritableDirectory(
@@ -83,7 +83,7 @@ test('fresh Hachidori settings inherit deck, tags, a unique model and configured
assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false });
assert.deepEqual(
await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'),
['http://127.0.0.1:8766', 'Mining', 'Japanese', [...config.tags, 'hachidori']],
['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags],
);
assert.deepEqual(
await h.run(
@@ -102,16 +102,6 @@ test('fresh Hachidori settings inherit deck, tags, a unique model and configured
assert.equal(await h.run('writes'), 1);
});
test('leaves tags edited in Hachidori settings alone', async () => {
const h = await harness({
templates: [
{ id: 'default', name: 'Default', deck: 'Mining', model: 'Japanese', tags: ['SubMiner'] },
],
});
await h.sync();
assert.deepEqual(await h.run('options.anki.tags'), ['SubMiner']);
});
// SubMiner's new-card polling only watches ankiConnect.deck, so the first
// template's deck follows it the way Yomitan's term card deck does.
test('moves the first template to the SubMiner deck and preserves the rest of custom templates', async () => {
@@ -49,10 +49,8 @@ export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
// SubMiner's new-card polling only watches its configured deck, so the
// first template follows it like Yomitan's term card deck.
if (deck) template.deck = deck;
// SubMiner's tags join Hachidori's own so cards show which backend mined
// them. Only the untouched default is filled; edits in Hachidori settings win.
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
template.tags = [...new Set([...hints.tags, 'hachidori'])];
template.tags = [...hints.tags];
}
let pending = false;
// Advanced templates include intentionally blank fields. Preserve them
@@ -38,6 +38,7 @@ function lookupResult(matched: string, expression: string, reading: string, dict
type UploadReply = Record<string, unknown>;
// The parts of vendor/hachidori/extension/linked-import.js the harness drives.
type LinkedImportModule = {
ENGINE_BUSY_CODE: string;
createUploadHost(options: {
store: {
append(token: string, data: string): void;
@@ -84,6 +85,7 @@ async function createHarness(emptyLibrary = false) {
let proxyUrl: unknown;
let loadingStatusReplies = 0;
let busyEngineReplies = 0;
let busyCommits = 0;
const uploads: Array<{ fileName: string; replace: boolean; bytes: Buffer }> = [];
const apiModule: unknown = await import(
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
@@ -106,6 +108,10 @@ async function createHarness(emptyLibrary = false) {
token: string,
{ fileName, replace }: { fileName: string; replace: boolean },
) => {
if (busyCommits > 0) {
busyCommits -= 1;
return { ok: false, error: 'busy', errorCode: linkedImportModule.ENGINE_BUSY_CODE };
}
uploads.push({ fileName, replace, bytes: Buffer.concat(uploadParts.get(token) ?? []) });
return { ok: true, report: { success: true } };
},
@@ -274,6 +280,9 @@ async function createHarness(emptyLibrary = false) {
setBusyEngineReplies: (count: number) => {
busyEngineReplies = count;
},
setBusyCommits: (count: number) => {
busyCommits = count;
},
uploads: () => uploads,
};
}
@@ -532,6 +541,8 @@ test('Hachidori mining requests render native dictionary aliases and frequency m
test('Hachidori settings automation uploads ZIP bytes as a replacement and removes the matching dictionary ID', async () => {
const harness = await createHarness();
// The engine is mid-change for the first commit; the same upload must still land once.
harness.setBusyCommits(1);
await harness.run(
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEstdGVzdA==', 'characters.zip')",
);
@@ -209,11 +209,20 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
};
// Archives go through Hachidori's upload requests: an unlinked install imports
// the ZIP itself and a linked one forwards it to its host. The engine queues
// an import behind any dictionary change already running.
const sendUpload = (type, fields) => chrome.runtime.sendMessage({
target: 'hachidori-linked-import', type, requestId: crypto.randomUUID(), ...fields,
});
// the ZIP itself and a linked one forwards it to its host. A busy engine keeps
// the upload open, so the same commit is sent again.
const sendUpload = async (type, fields) => {
const deadline = Date.now() + 300000;
for (;;) {
const reply = await chrome.runtime.sendMessage({
target: 'hachidori-linked-import', type, requestId: crypto.randomUUID(), ...fields,
});
if (type !== 'hd_import_commit' || reply?.errorCode !== 'engine-mutating' || Date.now() >= deadline) {
return reply;
}
await sleep(500);
}
};
async function importArchive(blob, fileName) {
await waitForEngineReady();
const reply = await uploadDictionary({ blob, fileName, replace: true, send: sendUpload });
@@ -1,12 +1,23 @@
import { MergedToken, PartOfSpeech } from '../../../types';
import { widenHalfwidthKana } from '../../utils/halfwidth-kana';
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
// No `u` flag: the range is entirely BMP so it changes nothing here, and
// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
const HALFWIDTH_KANA_RUN = /[\uff66-\uff9f]+/g;
// NFKC over the halfwidth kana only, never the whole string: it composes the
// voiced pairs (カ + ゙) into single characters so ガク compares equal to ガク
// instead of counting one character longer than the word it spells, but run
// over everything it would also rewrite unrelated text (① → 1, ㍑ → リットル).
function composeHalfwidthKana(text: string): string {
return text.replace(HALFWIDTH_KANA_RUN, (run) => run.normalize('NFKC'));
}
export function normalizeKana(text: string): string {
const raw = widenHalfwidthKana(text).trim();
const raw = composeHalfwidthKana(text).trim();
if (!raw) {
return '';
}
@@ -872,9 +872,6 @@ async function serveDictionaryZipOnce<T>(
}
return await callback(`http://127.0.0.1:${address.port}${requestPath}`);
} finally {
// The extension's fetch keeps its socket open; close() alone would wait out
// Node's keep-alive timeout before the import reports completion.
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
-24
View File
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { narrowFullwidthKana, widenHalfwidthKana } from './halfwidth-kana';
test('widenHalfwidthKana widens halfwidth katakana and composes voiced marks', () => {
assert.equal(
widenHalfwidthKana('(トーカ)ダキニお姉ちゃん アンタがアータルたん。'),
'(トーカ)ダキニお姉ちゃん アンタがアータルたん。',
);
});
test('widenHalfwidthKana leaves other compatibility characters alone', () => {
assert.equal(widenHalfwidthKana('見て見てっ!①㍑AB'), '見て見てっ!①㍑AB');
});
test('widenHalfwidthKana folds a lone halfwidth voiced mark into the kana before it', () => {
assert.equal(widenHalfwidthKana('山ガク パン'), '山ガク パン');
assert.equal(widenHalfwidthKana('ア゙'), 'ア\u3099');
});
test('narrowFullwidthKana inverts widening and leaves other text alone', () => {
assert.equal(narrowFullwidthKana('ダキニお姉ちゃん アンタ。ヴ'), 'ダキニお姉ちゃん アンタ。ヴ');
assert.equal(narrowFullwidthKana('漢字ひらがな!'), '漢字ひらがな!');
});
-49
View File
@@ -1,49 +0,0 @@
// No `u` flag: the range is entirely BMP so it changes nothing here, and
// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
// The optional leading character is captured so a run opening with a voiced
// mark (ガ) can fold the mark into the fullwidth kana before it.
const HALFWIDTH_KANA_RUN = /([^。-゚]?)([。-゚]+)/g;
const LEADING_COMBINING_VOICED_MARK = /^[゙゚]/;
/**
* Rewrites halfwidth katakana and its punctuation (アンタ, ダキニ, 。「」、・) to the fullwidth
* forms. Broadcast-derived subtitles write katakana halfwidth to save space; subtitle
* text is normalized with this where it enters the app (live mpv text, parsed cues) so
* display, lookups, mining, and stats all see one form.
*
* NFKC runs over the halfwidth runs only, never the whole string: it composes the voiced
* pairs (カ + ゙) into single characters, but over everything it would also rewrite
* unrelated text (① → 1, ! → !, ㍑ → リットル).
*/
export function widenHalfwidthKana(text: string): string {
return text.replace(HALFWIDTH_KANA_RUN, (_match, previous: string, run: string) => {
const widened = run.normalize('NFKC');
if (!previous || !LEADING_COMBINING_VOICED_MARK.test(widened)) {
return previous + widened;
}
// A lone ゙ widens to a combining mark; NFC composes it with the kana before it
// (カ + ゛ → ガ) and leaves pairs that have no composed form untouched.
return (previous + widened[0]).normalize('NFC') + widened.slice(1);
});
}
// Inverse of widenHalfwidthKana per character, derived from it so the two stay in sync.
// Voiced pairs (ガ) are included because they widen to a single character (ガ).
const HALFWIDTH_BY_FULLWIDTH = new Map<string, string>();
for (let code = 0xff61; code <= 0xff9f; code += 1) {
const halfwidth = String.fromCharCode(code);
for (const form of [halfwidth, `${halfwidth}゙`, `${halfwidth}゚`]) {
const widened = widenHalfwidthKana(form);
if (widened.length === 1 && !HALFWIDTH_BY_FULLWIDTH.has(widened)) {
HALFWIDTH_BY_FULLWIDTH.set(widened, form);
}
}
}
/**
* Rewrites fullwidth katakana and punctuation to halfwidth forms (アンタ → アンタ). Used to
* search text stored before subtitle ingest widened it; display paths should widen instead.
*/
export function narrowFullwidthKana(text: string): string {
return Array.from(text, (char) => HALFWIDTH_BY_FULLWIDTH.get(char) ?? char).join('');
}
+4 -23
View File
@@ -1,3 +1,4 @@
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2026 sudacode
@@ -37,7 +38,6 @@ import {
createHachidoriExtensionRuntime,
getHachidoriSession,
} from './core/services/hachidori-extension';
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
import {
DICTIONARY_EXTERNAL_LINK_CHANNEL,
parseDictionaryExternalUrl,
@@ -319,7 +319,6 @@ import {
replayCurrentSubtitleRuntime,
resolveSanitizedSubtitleSeekCommand,
resolveJellyfinPlaybackPlanRuntime,
toJellyfinOriginalStreamUrl,
runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore,
clearYomitanParserCachesForWindow,
@@ -402,11 +401,7 @@ import {
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import {
findMpvExecutablePath,
resolveMpvExecutablePath,
spawnMpvProcess,
} from './main/runtime/mpv-process';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -3083,11 +3078,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
// An invalid configured path is passed through so the preview fails instead of using PATH mpv.
getMpvExecutablePath: () => {
const configuredPath = configService.getConfig().mpv.executablePath;
return findMpvExecutablePath(configuredPath) || configuredPath;
},
getMpvExecutablePath: () =>
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
@@ -6932,17 +6924,6 @@ const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
}),
resolveYoutubeSourceUrl: (mediaPath) =>
youtubePlaybackRuntime.getYoutubeSourceUrlForStream(mediaPath),
resolveJellyfinMedia: (mediaPath) => {
const playback = activeJellyfinRemotePlayback;
const sourceUrl = playback && toJellyfinOriginalStreamUrl(mediaPath, playback.itemId);
return sourceUrl
? {
sourceUrl,
transcoded: playback.playMethod === 'Transcode',
audioStreamIndex: playback.audioStreamIndex ?? null,
}
: null;
},
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
-75
View File
@@ -5,11 +5,9 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
createPosixMpvPathDeps,
createWindowsMpvPathDeps,
getConfiguredWindowsMpvPathStatus,
resolveMpvExecutablePath,
resolvePosixMpvPath,
resolveWindowsMpvPath,
spawnMpvProcess,
} from './mpv-process';
@@ -97,79 +95,6 @@ test('resolveWindowsMpvPath returns blank when nothing resolves', () => {
assert.equal(resolveWindowsMpvPath(missing.deps), '');
});
// launchd's default PATH, which apps opened from Finder, the Dock, or Spotlight inherit.
const LAUNCHD_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
function makePosixDeps(options: {
platform: NodeJS.Platform;
executables?: string[];
env?: Record<string, string>;
}) {
const executables = new Set(options.executables ?? []);
return createPosixMpvPathDeps({
platform: options.platform,
getEnv: (name) => options.env?.[name],
isExecutable: (candidate) => executables.has(candidate),
});
}
test('resolvePosixMpvPath prefers a configured executable and does not fall back when it is invalid', () => {
const deps = makePosixDeps({
platform: 'darwin',
executables: ['/cfg/mpv', '/opt/homebrew/bin/mpv'],
env: { PATH: LAUNCHD_PATH },
});
assert.equal(resolvePosixMpvPath(deps, ' /cfg/mpv '), '/cfg/mpv');
assert.equal(resolvePosixMpvPath(deps, '/missing/mpv'), '');
});
test('resolvePosixMpvPath uses SUBMINER_MPV_PATH before searching PATH', () => {
const deps = makePosixDeps({
platform: 'linux',
executables: ['/env/mpv', '/usr/bin/mpv'],
env: { SUBMINER_MPV_PATH: ' /env/mpv ', PATH: '/usr/bin' },
});
assert.equal(resolvePosixMpvPath(deps), '/env/mpv');
});
test('resolvePosixMpvPath takes the first PATH entry that has mpv', () => {
const deps = makePosixDeps({
platform: 'darwin',
executables: ['/second/mpv', '/third/mpv', '/opt/homebrew/bin/mpv'],
env: { SUBMINER_MPV_PATH: '/stale/mpv', PATH: '/first::/second:/third' },
});
assert.equal(resolvePosixMpvPath(deps), '/second/mpv');
});
test('resolvePosixMpvPath finds Homebrew mpv on macOS when the app only has the launchd PATH', () => {
const homebrew = makePosixDeps({
platform: 'darwin',
executables: ['/opt/homebrew/bin/mpv'],
env: { PATH: LAUNCHD_PATH },
});
const appBundle = makePosixDeps({
platform: 'darwin',
executables: ['/Applications/mpv.app/Contents/MacOS/mpv'],
env: { PATH: LAUNCHD_PATH },
});
assert.equal(resolvePosixMpvPath(homebrew), '/opt/homebrew/bin/mpv');
assert.equal(resolvePosixMpvPath(appBundle), '/Applications/mpv.app/Contents/MacOS/mpv');
});
test('resolvePosixMpvPath only probes macOS install locations on macOS', () => {
const deps = makePosixDeps({
platform: 'linux',
executables: ['/opt/homebrew/bin/mpv'],
env: { PATH: LAUNCHD_PATH },
});
assert.equal(resolvePosixMpvPath(deps), '');
});
const X11_SPAWN_CASES = [
{
name: 'forces the X11 backend for an unsupported Wayland session',
+5 -66
View File
@@ -1,5 +1,4 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import {
MPV_X11_BACKEND_ARGS,
@@ -99,74 +98,14 @@ export function spawnMpvProcess(
});
}
export interface PosixMpvPathDeps {
platform: NodeJS.Platform;
getEnv: (name: string) => string | undefined;
isExecutable: (candidate: string) => boolean;
}
// Apps opened from Finder, the Dock, or Spotlight inherit launchd's minimal PATH
// (/usr/bin:/bin:/usr/sbin:/sbin), so Homebrew and app-bundle installs are probed directly.
const MACOS_MPV_FALLBACK_PATHS = [
'/opt/homebrew/bin/mpv',
'/usr/local/bin/mpv',
'/Applications/mpv.app/Contents/MacOS/mpv',
];
function isExecutableFile(candidate: string): boolean {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function createPosixMpvPathDeps(
overrides: Partial<PosixMpvPathDeps> = {},
): PosixMpvPathDeps {
return {
platform: overrides.platform ?? process.platform,
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
isExecutable: overrides.isExecutable ?? isExecutableFile,
};
}
// Resolution order: mpv.executablePath, SUBMINER_MPV_PATH, PATH, then macOS install
// locations. A configured path that is not executable resolves to '' instead of falling back.
export function resolvePosixMpvPath(deps: PosixMpvPathDeps, configuredMpvPath = ''): string {
const configPath = configuredMpvPath.trim();
if (configPath) {
return deps.isExecutable(configPath) ? configPath : '';
}
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
if (envPath && deps.isExecutable(envPath)) {
return envPath;
}
const pathCandidates = (deps.getEnv('PATH') ?? '')
.split(path.posix.delimiter)
.filter((dir) => dir.length > 0)
.map((dir) => path.posix.join(dir, 'mpv'));
const candidates =
deps.platform === 'darwin' ? [...pathCandidates, ...MACOS_MPV_FALLBACK_PATHS] : pathCandidates;
return candidates.find((candidate) => deps.isExecutable(candidate)) ?? '';
}
// Returns the mpv binary SubMiner should spawn itself, or '' when none is found.
export function findMpvExecutablePath(configuredMpvPath = ''): string {
return process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: resolvePosixMpvPath(createPosixMpvPathDeps(), configuredMpvPath);
}
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
const executablePath = findMpvExecutablePath(configuredMpvPath);
const executablePath =
process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: 'mpv';
if (!executablePath) {
const binary = process.platform === 'win32' ? 'mpv.exe' : 'mpv';
throw new Error(
`Could not find ${binary}. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.`,
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
);
}
return executablePath;
+1 -5
View File
@@ -3,7 +3,6 @@ import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
@@ -27,14 +26,11 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool
);
}
// Live text also arrives from direct `sub-text` reads that bypass the mpv protocol
// handler, so it is widened here too to match the parsed cues.
function decodedLiveText(
liveText: string,
cues: readonly SubtitleCue[] | null | undefined,
): string {
const text = widenHalfwidthKana(liveText);
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(text) : text;
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
@@ -1,10 +1,8 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
import {
createSubtitleGenerationRuntime,
type SubtitleGenerationJellyfinMedia,
type SubtitleGenerationRuntimeDeps,
} from './subtitle-generation-runtime';
@@ -508,101 +506,3 @@ test('switching to another resolved YouTube stream cancels the modal job', async
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
});
function jellyfinFixture(
jellyfin: Omit<SubtitleGenerationJellyfinMedia, 'sourceUrl'>,
overrides: Partial<SubtitleGenerationRuntimeDeps> = {},
) {
const playing = 'https://jf.example/Videos/item/master.m3u8?ApiKey=secret';
const subject = fixture({
resolveJellyfinMedia: (mediaPath) =>
mediaPath === playing
? {
sourceUrl: 'https://jf.example/Videos/item/stream?static=true&ApiKey=secret',
...jellyfin,
}
: null,
createStreamOutputDirectory: async () => '/tmp/generated',
...overrides,
});
subject.client.requestProperty = async (name) =>
name === 'path'
? playing
: name === 'media-title'
? 'Show S01E02'
: name === 'track-list'
? [
{ type: 'audio', selected: true, 'ff-index': 0 },
{ type: 'sub', lang: 'eng', title: 'English Full', 'ff-index': 4 },
{
type: 'sub',
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/jellyfin-subs/track-5.srt',
},
]
: null;
return subject;
}
test('a direct-play Jellyfin stream generates from its original file and shows by title', async () => {
let outputPath = '';
const subject = jellyfinFixture(
{ transcoded: false, audioStreamIndex: 2 },
{
generate: async (input) => {
assert.equal(
input.mediaPath,
'https://jf.example/Videos/item/stream?static=true&ApiKey=secret',
);
outputPath = input.outputPath ?? '';
assert.equal(path.dirname(outputPath), path.join('/tmp', 'generated'));
assert.match(path.basename(outputPath), /^jellyfin-\d+\.ja\.generated\.srt$/);
assert.equal(input.audioStreamIndex, 0);
assert.deepEqual(
input.references?.map((reference) => reference.source.kind),
['embedded', 'external'],
);
return outputPath;
},
},
);
assert.equal((await subject.runtime.getStatus()).mediaPath, 'Jellyfin: Show S01E02');
assert.equal((await subject.runtime.start()).ok, true);
assert.deepEqual(subject.commands[0], [
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
});
test('a transcoded Jellyfin stream uses the Jellyfin audio index and only external references', async () => {
const subject = jellyfinFixture(
{ transcoded: true, audioStreamIndex: 2 },
{
generate: async (input) => {
assert.equal(input.audioStreamIndex, 2);
assert.deepEqual(
input.references?.map((reference) => reference.source.kind),
['external'],
);
return '/tmp/generated/jellyfin.ja.generated.srt';
},
},
);
assert.equal((await subject.runtime.start()).ok, true);
});
test('unrecognized streams cannot generate', async () => {
const subject = jellyfinFixture(
{ transcoded: false, audioStreamIndex: null },
{ resolveJellyfinMedia: () => null },
);
assert.equal((await subject.runtime.getStatus()).mediaPath, null);
const result = await subject.runtime.start();
assert.equal(result.ok, false);
assert.match(result.message, /Jellyfin stream/);
});
+15 -78
View File
@@ -1,5 +1,3 @@
import { mkdir, readdir, rm } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
@@ -31,22 +29,6 @@ interface GenerationMpvClient {
request: (command: unknown[]) => Promise<{ error?: string }>;
}
/** A Jellyfin stream playing in mpv, as subtitle generation reads it. */
export interface SubtitleGenerationJellyfinMedia {
/** The item's original file. It carries the access token, so it never reaches status text. */
sourceUrl: string;
/**
* Transcoded playback has its own stream layout, so mpv's track indexes do not address the
* original file. Generation then uses Jellyfin's audio index and skips embedded references.
*/
transcoded: boolean;
audioStreamIndex: number | null;
}
type GenerationMedia =
| { kind: 'local'; path: string }
| { kind: 'jellyfin'; path: string; jellyfin: SubtitleGenerationJellyfinMedia };
export interface SubtitleGenerationRuntimeDeps {
getConfig: () => SubtitleGenerationConfig;
getModelDirectory: () => string;
@@ -74,27 +56,11 @@ export interface SubtitleGenerationRuntimeDeps {
* resolved direct stream in mpv, so mpv's path alone does not identify the video.
*/
resolveYoutubeSourceUrl?: (mediaPath: string) => string | null;
/** Identifies mpv's path as Jellyfin playback. Without it, streams cannot generate. */
resolveJellyfinMedia?: (mediaPath: string) => SubtitleGenerationJellyfinMedia | null;
/**
* Directory for subtitles of streamed media, which has no folder of its own. Called once per
* stream generation, so it may delete what an earlier run wrote there.
*/
createStreamOutputDirectory?: () => Promise<string>;
}
// One reused folder keeps stream subtitles from piling up: each run deletes earlier files.
// mpv has already parsed a loaded track, and a file still locked (Windows) waits for a later run.
async function reuseStreamOutputDirectory(): Promise<string> {
const directory = path.join(homedir(), '.cache', 'subminer', 'generated-subs');
await mkdir(directory, { recursive: true });
for (const name of await readdir(directory))
await rm(path.join(directory, name), { recursive: true, force: true }).catch(() => undefined);
return directory;
}
// Resolves mpv's `path` value to an absolute local file, or null for URLs.
async function localMediaPath(client: GenerationMpvClient, media: unknown): Promise<string | null> {
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
if (typeof media !== 'string' || !media || /^[a-z][a-z\d+.-]*:\/\//i.test(media)) return null;
if (path.isAbsolute(media)) return path.normalize(media);
const directory = await client.requestProperty('working-directory');
@@ -173,22 +139,6 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
function youtubeSource(mediaPath: string | null | undefined): string | null | undefined {
return (mediaPath && deps.resolveYoutubeSourceUrl?.(mediaPath)) || mediaPath;
}
async function currentMedia(client: GenerationMpvClient | null): Promise<GenerationMedia | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
if (typeof media === 'string' && /^https?:\/\//i.test(media)) {
const jellyfin = deps.resolveJellyfinMedia?.(media);
return jellyfin ? { kind: 'jellyfin', path: media, jellyfin } : null;
}
const local = await localMediaPath(client, media);
return local ? { kind: 'local', path: local } : null;
}
// Stream URLs carry access tokens, so Jellyfin media shows by title.
async function describeMedia(client: GenerationMpvClient, media: GenerationMedia) {
if (media.kind === 'local') return media.path;
const title = await client.requestProperty('media-title').catch(() => null);
return `Jellyfin: ${typeof title === 'string' && title.trim() ? title.trim() : 'current item'}`;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
@@ -246,10 +196,9 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
deps.getModelDirectory(),
);
const client = deps.getMpvClient();
const media = client ? await currentMedia(client).catch(() => null) : null;
const mediaPath =
youtubeJobUrl ??
(client && media ? await describeMedia(client, media) : null) ??
(await currentLocalMedia(client).catch(() => null)) ??
(deps.generateYoutube ? await currentYoutubeMedia(client).catch(() => null) : null);
return {
model,
@@ -401,33 +350,21 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
const youtubeUrl = deps.generateYoutube ? await currentYoutubeMedia(client) : null;
if (client && youtubeUrl && deps.generateYoutube)
return await startYoutube(client, youtubeUrl, config, signal, deps.generateYoutube);
const media = await currentMedia(client);
if (!client || !media)
throw new Error(
'Open a local video or audio file, a Jellyfin stream, or a YouTube video in mpv first.',
);
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file, or a YouTube video, in mpv first.');
const tracks = await client.requestProperty('track-list');
const transcoded = media.kind === 'jellyfin' && media.jellyfin.transcoded;
const audioStreamIndex = transcoded
? (media.jellyfin.audioStreamIndex ?? undefined)
: selectedAudioIndex(tracks);
const references = (
await readSubtitleGenerationReferences(tracks, (name) => client.requestProperty(name))
).filter((reference) => !transcoded || reference.source.kind === 'external');
if ((await currentMedia(client))?.path !== media.path)
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
client.requestProperty(name),
);
if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted();
const outputPath = await (deps.generate ?? generateJapaneseSubtitles)({
config,
modelDirectory: deps.getModelDirectory(),
mediaPath: media.kind === 'jellyfin' ? media.jellyfin.sourceUrl : media.path,
outputPath:
media.kind === 'jellyfin'
? path.join(
await (deps.createStreamOutputDirectory ?? reuseStreamOutputDirectory)(),
`jellyfin-${Date.now()}.ja.generated.srt`,
)
: undefined,
mediaPath,
audioStreamIndex,
references,
onProgress: report,
@@ -435,8 +372,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
});
// Saving succeeds even if playback changes or disconnects during the job.
try {
const playingMedia = (await currentMedia(client))?.path;
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === media.path) {
const playingMedia = await currentLocalMedia(client);
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
await loadGeneratedSubtitles(client, outputPath);
return {
ok: true,
-12
View File
@@ -256,18 +256,6 @@ test('youtube flow announces manual picker opening before probing tracks', async
assertNoProblems(harness);
});
test('youtube flow widens halfwidth katakana in the refreshed subtitle', async () => {
const harness = createFlowHarness({
mpv: { subText: 'アンタがアータルたん?' },
probeTracks: [primaryTrack],
pick: { primaryTrackId: primaryTrack.id, secondaryTrackId: null },
});
await harness.runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(harness.refreshedSubtitles, ['アンタがアータルたん?']);
});
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const harness = createFlowHarness({
probeTracks: [primaryTrack, secondaryTrack],
+1 -2
View File
@@ -21,7 +21,6 @@ import {
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
import { extractYoutubeVideoId } from './youtube-playback';
import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
@@ -458,7 +457,7 @@ async function injectDownloadedSubtitles(
const currentSubText = await deps.requestMpvProperty('sub-text').catch(() => null);
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
deps.refreshCurrentSubtitle(widenHalfwidthKana(currentSubText));
deps.refreshCurrentSubtitle(currentSubText);
}
deps.showMpvOsd(secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.');
+2 -3
View File
@@ -88,7 +88,7 @@ export function createSubtitleGenerationModal(
const vad = snapshot ? describeGenerationVad(snapshot.vad) : null;
const tools = snapshot ? describeGenerationTools(snapshot.tools) : null;
const readyMessage = !snapshot?.mediaPath
? 'Open a local file, a Jellyfin stream, or a YouTube video to generate subtitles.'
? 'Open a local file or a YouTube video to generate subtitles.'
: !tools?.ready
? 'Install the missing tools or set their paths in Settings, then click Check again.'
: !model?.ready
@@ -97,8 +97,7 @@ export function createSubtitleGenerationModal(
? 'Download the speech detection model or uncheck Focus on spoken dialogue.'
: 'Ready when you are.';
dom.media.textContent =
snapshot?.mediaPath ??
'Open a local file, a Jellyfin stream, or a YouTube video in the player first.';
snapshot?.mediaPath ?? 'Open a local file or a YouTube video in the player first.';
dom.tools.textContent = tools?.text ?? 'Checking local tools...';
dom.model.textContent = model?.text ?? 'Checking local models...';
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));