Compare commits

...
21 Commits
Author SHA1 Message Date
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 7c161723cd fix(anki): regenerate sentence furigana from the final sentence (#268) 2026-09-22 21:43:19 -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 b0fb39a8f5 feat(overlay): add optional subtitle selection modal (#265) 2026-09-22 18:30:26 -07:00
Abdulrazzaq Alhendi a7a302bdd0 fix(jellyfin): respect Windows mpv configuration when casting (#267) 2026-09-22 18:29:53 -07:00
sudacode 6a1d1211ae fix(release): resolve style.css asar lookup with native separators
The shared-font check still handed asar a forward-slash path, which
fails on Windows the same way the inventory listing did.
2026-09-22 02:29:28 -07:00
sudacode 7585d430ae fix(release): unbreak packaging workflow on all three platforms
- Bump electron-builder to 26.16.1 so macOS signing passes the temp keychain
  password to set-key-partition-list (upstream #10066), which the macOS 26
  runner image now enforces
- Stat asar entries with their native path in the package audit; splitting on
  path.sep made every nested file look missing on Windows
- Run the package smoke with --no-sandbox on Linux runners without a setuid
  chrome-sandbox
- Load the packaged stats dashboard over loopback HTTP in the smoke, matching
  how the app serves it since #263, and ignore Chromium's cache-only font probe
2026-09-22 02:15:49 -07:00
sudacode 122ff9a3c5 chore(release): prepare v0.20.0-beta.1
Trim pending change fragments, drop the breaking flag from the stats
request-safety change, and generate prerelease notes.
2026-09-22 01:29:58 -07:00
sudacode 786bd5af81 chore(yomitan): update vendored fork to upstream 26.9.8 2026-09-21 20:53:26 -07:00
sudacode 42d1384c68 fix(jellyfin): support modern authentication (#264) 2026-09-21 20:40:57 -07:00
144 changed files with 3429 additions and 463 deletions

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ jobs:
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Verify native mpv process launch
run: bun test src/main/runtime/mpv-process.test.ts
- name: Verify POSIX launcher bootstrap
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
+10 -10
View File
@@ -22,7 +22,7 @@
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.15.3",
"electron-builder": "26.16.1",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
@@ -36,9 +36,9 @@
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.15.3",
"app-builder-lib": "26.16.1",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.6",
"form-data": "4.0.6",
"ip-address": "10.2.0",
@@ -181,7 +181,7 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
@@ -243,7 +243,7 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="],
"app-builder-lib": ["app-builder-lib@26.16.1", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.16.1", "electron-builder-squirrel-windows": "26.16.1" } }, "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -273,7 +273,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="],
"builder-util": ["builder-util@26.16.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg=="],
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
@@ -335,7 +335,7 @@
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="],
"dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -349,11 +349,11 @@
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
"electron-builder": ["electron-builder@26.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=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A=="],
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
"electron-publish": ["electron-publish@26.16.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
+1 -2
View File
@@ -1,5 +1,4 @@
type: docs
area: launcher
- Documented private-runtime launcher installation, simplified first-run setup, custom app locations, legacy launcher migration, and package-managed updates. Updated release-note download guidance for the bundled runtime and Windows launcher.
- Documented the bundled Bun runtime's MIT and LGPL licensing, where the notices live in the app, and the corresponding-source release asset. The AUR package now lists the bundled runtime licenses and installs their notices under `/usr/share/licenses/subminer-bin`, and `subminer-assets.tar.gz` includes the same notices.
- Documented the bundled-runtime launcher install, legacy launcher migration, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices (installed under `/usr/share/licenses/subminer-bin` by the AUR package and included in `subminer-assets.tar.gz`).
+1 -2
View File
@@ -1,5 +1,4 @@
type: changed
area: launcher
- Made every installed and downloadable launcher use the Bun runtime included with SubMiner. Added a Windows `subminer.cmd` download, persistent private runtime caches for Linux and Windows, and migration for recognized legacy launchers. Deferred migrations survive overlapping update checks and remain pending when startup cannot safely refresh the launcher.
- Simplified first-run setup to a single optional launcher control, with runtime repair guidance shown only when needed.
- Every installed and downloadable launcher now uses the Bun runtime bundled with SubMiner instead of a system Bun. Recognized legacy launchers migrate automatically, Windows gets a `subminer.cmd` download, and first-run setup is reduced to a single optional launcher control with runtime repair guidance shown only when needed.
+1 -3
View File
@@ -1,6 +1,4 @@
type: changed
area: sync
- Sync uses compressed, incremental rsync transfers on compatible macOS and Linux machines, caching the last received snapshot per peer to reduce traffic on subsequent syncs. Cache helpers work through the launcher; older apps and launchers fall back to compressed transfers without an upload cache.
- Machines without compatible rsync, including Windows endpoints, automatically use compressed scp transfers.
- Rsync explicitly uses SSH and aborts transfers that exceed 30 minutes before merging.
- Sync now uses compressed, incremental rsync transfers between compatible macOS and Linux machines, caching the last received snapshot per peer to cut traffic on later syncs. Machines without compatible rsync (including Windows) fall back to compressed scp, and older peers still work without the upload cache. Transfers abort after 30 minutes.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: anki
- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`.
- Added `ankiConnect.fields.wordAudio` so word audio is read separately from the sentence-audio destination, fixing animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`.
+1 -4
View File
@@ -1,7 +1,4 @@
type: fixed
area: jellyfin
- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats.
- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived.
- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments.
- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty.
- Jellyfin streams no longer leak URL-derived titles or credential-bearing stream URLs into metadata lookups, Anki source fields, Discord presence, stats identities, or AniList retry keys. Previously cached credential-bearing parser metadata is cleaned up without touching watch history or library assignments.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: jellyfin
- Jellyfin playback, subtitle, artwork, and remote-control URLs now authenticate with the `ApiKey` query parameter instead of the legacy `X-Emby-*` headers, so the integration works on Jellyfin 12 where legacy authorization is disabled by default.
- "Play on SubMiner" keeps working on Jellyfin 12: the cast-target websocket answers keep-alive requests and reconnects when the server stops replying instead of silently dying after about a minute.
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
- Anki cards mined from Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
+1 -2
View File
@@ -1,5 +1,4 @@
type: fixed
area: stats
- Stats server startup reports port conflicts without crashing SubMiner, shares concurrent startup requests, and shows in-app startup errors through configured status notifications.
- Background stop cancels pending background startup without disconnecting foreground-only dashboards. Shutdown bounds the wait for active HTTP requests and awaits tracker finalization before exit, with a deadline for forced application exit.
- Stats server port conflicts are reported through status notifications instead of crashing SubMiner. Startup and shutdown are also more robust: concurrent startup requests are shared, background stop no longer disconnects foreground dashboards, and shutdown bounds how long it waits for active requests.
@@ -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.
+2 -2
View File
@@ -2,9 +2,9 @@ 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` continues to open Yomitan settings. With Hachidori active, an inactive external Yomitan profile does not block the bundled Yomitan settings.
- 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. Linked Docker hosts receive character dictionary uploads through `hachidori.externalHostManagementUrl`, retry busy imports, and replace the previous dictionary only after a successful import.
- 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. Anki discovery retries after an unavailable connection.
- 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.
+1 -2
View File
@@ -1,5 +1,4 @@
type: docs
area: subtitles
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process.
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
- Documented subtitle generation: model choice, VAD behavior, reference-track guided splitting, fallback behavior, and known limits.
+4 -5
View File
@@ -1,8 +1,7 @@
type: added
area: subtitles
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections. Use a wider generation modal to reduce text wrapping and scrolling, while keeping it within smaller player windows.
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
- Generate Japanese SRT subtitles locally with whisper.cpp from a modal (Ctrl+Shift+G), the empty subtitle sidebar's generation button, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv.
- Pick an official multilingual model (including quantized variants) with size and accuracy guidance and download it in-app, or point Settings at an existing model. `large-v3-turbo` is recommended when CUDA support is detected, `small` otherwise. whisper-cli, ffmpeg, and ffprobe are found on PATH unless overridden, and missing tools are named before any download starts.
- Optional "Focus on spoken dialogue" mode uses a separately downloadable Silero VAD model, keeping uncertain audible sections so dialogue under music is not dropped (songs may be transcribed too).
- Long passages are split near detected speech starts or quiet pauses, guided by an eligible embedded or external subtitle track already loaded in mpv when one is available, to reduce early subtitle timing. Each passage runs in a fresh Whisper process to avoid repeated-character output.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: jellyfin
- Clarify that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Honor the configured mpv executable when Jellyfin starts playback, allowing casting when mpv is installed outside PATH, and detect portable plugins beside the selected executable.
+1 -2
View File
@@ -1,5 +1,4 @@
type: docs
area: jellyfin
- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata.
- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata.
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: overlay
- The overlay discovers non-conflicting keyboard bindings from mpv defaults, input.conf, and loaded scripts in the background. SubMiner controls and explicitly disabled bindings take precedence. Discovered bindings stay session-only and do not appear in SubMiner's help menu.
- The overlay picks up non-conflicting keyboard bindings from mpv defaults, `input.conf`, and loaded scripts. SubMiner controls and explicitly disabled bindings take precedence; discovered bindings are session-only and not listed in the help menu.
+1 -2
View File
@@ -1,5 +1,4 @@
type: changed
area: release
- Reduced installer and unpacked app size by excluding documentation demo media, dependency source maps, TypeScript files, test and fixture directories, other development files, and unused Koffi platform binaries, and sharing the existing Japanese UI font across windows.
- Added package content checks, published size reports with release comparisons, and packaged asset/native-module smoke checks to the shared stable and prerelease build workflow. Size growth is reported without blocking releases.
- Reduced installer and unpacked app size by dropping demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries from the package, and sharing one Japanese UI font across windows. Release builds now publish package size reports with comparisons against the previous release.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Keep word-card sentence furigana in sync with full stats-search context and expanded timing-review selections. Clear stale readings if regeneration fails.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: overlay
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selecting does not seek or require mining a card.
+1 -3
View File
@@ -1,6 +1,4 @@
type: changed
breaking: true
area: stats
- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies.
- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard.
- The stats server now rejects requests from non-loopback hosts and browser origins and requires `application/json` for mutation bodies. The in-app stats overlay loads from the local server so it shares the same protection. Reverse-proxied or Tailscale Serve dashboards are unsupported; scripts that POST must set a JSON content type.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: config
- Documented the subtitle selector setting, shortcut override, and primary/secondary track controls.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added an optional Catppuccin subtitle selection modal for primary and secondary mpv tracks. Enable it in Settings under Behavior, then press g followed by s. Disabling it restores mpv's subtitle selection binding.
- Single-key actions take priority over configured sequence prefixes. Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
+1 -1
View File
@@ -1,4 +1,4 @@
type: docs
area: stats
- Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown.
- Documented TMDB linking, provider reassignment, merge compatibility, and credential command caching.
+3 -5
View File
@@ -1,8 +1,6 @@
type: added
area: stats
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library kind selector gained a Live Action option alongside Anime and YouTube.
- Provider reassignment preserves the previous link and artwork if the replacement download fails, and refreshes completion totals when the episode count changes. Merges and sync keep conflicting AniList and TMDB identities separate, and the merge dialog explains when a selection mixes the two instead of failing.
- TMDB credential commands cache successful output and wait 30 seconds before retrying failed or empty output, using the bundled key in the meantime when available.
- Live-action dramas and movies in the stats Library get posters, synopses, and titles from TMDB. Release builds include a project key; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it and is required when running from source.
- Titles AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
- Provider reassignment keeps the previous link and artwork if the replacement download fails. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
+1 -1
View File
@@ -1,4 +1,4 @@
type: docs
area: anki
- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze.
- Documented the separate word-audio field mapping; existing animated images need regeneration to pick up the fix.
+2 -4
View File
@@ -1,7 +1,5 @@
type: added
area: stats
- Store YouTube channels as a separate media kind and migrate existing channel entries without changing viewing history or manual video assignments, including channels created after temporarily returning to an older build.
- Add All Titles, Anime, and YouTube Library filters, identify channel pages, and keep channels out of AniList matching, season repair, and duplicate recommendations.
- Keep same-title anime and YouTube records separate in storage and sync while repairing legacy channel classification.
- Refuse merges and video moves that would mix an anime entry with a YouTube channel; the move picker and merge selection only combine entries of the same kind.
- YouTube channels are now a separate Library media kind. Existing channel entries migrate automatically without changing viewing history or manual video assignments.
- Added All Titles, Anime, and YouTube Library filters. Channels stay out of AniList matching, season repair, and duplicate recommendations, and merges or video moves cannot mix an anime entry with a YouTube channel.
+10
View File
@@ -22,6 +22,15 @@
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
// Hot-reload: enabling or disabling updates the session shortcut immediately.
// ==========================================
"subtitleSelection": {
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
}, // Select primary and secondary mpv subtitle tracks from the overlay.
// ==========================================
// Japanese Subtitle Generation
// Generate timed Japanese subtitles from local audio using whisper.cpp.
@@ -239,6 +248,7 @@
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
+6 -2
View File
@@ -23,11 +23,11 @@ AnkiConnect and Kiku/Senren settings follow the [configuration validation rules]
### Reusing SubMiner settings in Hachidori
When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It copies the configured deck and tags into untouched defaults, then fills missing word, sentence, pronunciation-audio, and picture mappings with fields that exist in Anki. Pronunciation uses `ankiConnect.fields.wordAudio`, falling back to `fields.audio` when no word-audio field is set.
When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It sets the template's deck to `ankiConnect.deck` when one is configured, copies the configured tags into untouched defaults, then fills missing word, sentence, pronunciation-audio, and picture mappings with fields that exist in Anki. Pronunciation uses `ankiConnect.fields.wordAudio`, falling back to `fields.audio` when no word-audio field is set.
If the note type is unset, SubMiner looks for a unique match containing its configured word and sentence fields. Enabled Lapis, Kiku, or Senren integration narrows the search; Lapis uses its configured model name. A fresh mapping also receives Hachidori's matching preset for readings, definitions, and other recognized fields. If several note types match, choose one in Hachidori Settings. If Anki is closed, open Hachidori Settings again after starting Anki to retry.
Existing custom decks, tags, field mappings, advanced templates, and additional templates stay intact. This fills missing settings rather than continually overwriting Hachidori choices. The Anki endpoint continues to follow SubMiner's proxy configuration. Sentence audio, image timing, translation, metadata, and duplicate field grouping remain controlled by SubMiner; pronunciation sources are configured in Hachidori. Linking an external dictionary host does not change this behavior.
The deck always follows `ankiConnect.deck`, as it does for Yomitan's mining deck, because polling mode only looks for new cards in that deck. Existing custom tags, field mappings, advanced templates, and additional templates stay intact. Apart from the deck, this fills missing settings rather than continually overwriting Hachidori choices. The Anki endpoint continues to follow SubMiner's proxy configuration. Sentence audio, image timing, translation, metadata, and duplicate field grouping remain controlled by SubMiner; pronunciation sources are configured in Hachidori. Linking an external dictionary host does not change this behavior.
## Auto-enrichment transport
@@ -202,6 +202,8 @@ Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
Playback stays paused while the review is open, even if the dictionary popup or subtitle hover that paused it goes away. When the review closes, playback resumes if it was playing before the review or if the popup closed in the meantime. A dictionary popup that is still open keeps playback paused.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
@@ -222,6 +224,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
When word-card enrichment changes the sentence context, including an expanded timing-review selection, SubMiner regenerates `SentenceFurigana` from the final sentence. Unchanged sentences keep their existing furigana formatting. If generation fails, SubMiner clears stale furigana so compatible templates can fall back to `Sentence`.
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
+8
View File
@@ -1125,6 +1125,14 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
## Subtitle Selection
Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals.
Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available.
`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults.
## External integrations
### Jimaku
+1 -1
View File
@@ -152,7 +152,7 @@ Stats server config lives under `stats`:
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - looks up the word with the selected dictionary backend, Yomitan or Hachidori, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. The selected history line supplies the card's context even while another subtitle is playing in mpv. Hachidori uses its configured Anki template, dictionary aliases, and frequency metadata. Requires Anki and the selected backend's dictionaries to be loaded.
- **Mine Word** - looks up the word with the selected dictionary backend, Yomitan or Hachidori, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. The selected history line supplies the card's context even while another subtitle is playing in mpv. Hachidori uses its configured Anki template, dictionary aliases, and frequency metadata. Requires Anki and the selected backend's dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
+2 -2
View File
@@ -120,10 +120,10 @@ pip install ffsubsync
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots). Put mpv on `PATH` or set `mpv.executablePath` during setup. ffmpeg must be on `PATH`.
::: tip What is PATH?
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner uses it to find ffmpeg and, unless an executable path is configured, mpv. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
:::
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
+2 -1
View File
@@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
## Requirements
- A Jellyfin server plus your username and password
- A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported)
- SubMiner installed and running (see [Installation](/installation))
- On Linux, the session token is stored with `gnome-libsecret` by default
@@ -50,6 +50,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
## What happens during playback
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **Windows respects your mpv settings.** Casting checks `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid configured path prevents automatic startup.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
+10
View File
@@ -22,6 +22,15 @@
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
// Hot-reload: enabling or disabling updates the session shortcut immediately.
// ==========================================
"subtitleSelection": {
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
}, // Select primary and secondary mpv subtitle tracks from the overlay.
// ==========================================
// Japanese Subtitle Generation
// Generate timed Japanese subtitles from local audio using whisper.cpp.
@@ -239,6 +248,7 @@
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
+3
View File
@@ -88,6 +88,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
@@ -98,6 +99,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
Enable the subtitle selector in **Settings → Behavior → Subtitle Selection**. Its shortcut overrides mpv subtitle selection only while enabled. In the focused overlay, press the second key within one second. Single-key bindings take priority: if `g` already has an action in SubMiner or mpv, `g-s` is disabled with a conflict warning, and `g` still runs immediately. Remap the sequence or remove the conflicting single-key binding. The existing `y` prefix is reserved for its built-in commands. mpv bindings are checked on connection, configuration changes, and overlay focus; refresh the overlay after changing another script's bindings. See [subtitle selection](/configuration#subtitle-selection).
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
+1 -1
View File
@@ -333,7 +333,7 @@ For automatic character dictionary sync with a Docker host, set `hachidori.exter
Existing controls such as `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to the selected backend. Hachidori has one dictionary configuration, so character-dictionary profile scope applies to that configuration.
First-run setup remembers each backend that finished it, including when setup is reopened for legacy plugin cleanup. Switching to a backend for the first time asks for that backend's dictionaries; switching back to one that already finished does not repeat setup. Until SubMiner restarts, it keeps running the backend it started with, and the launcher gates playback on that running backend and logs a restart reminder. A running Yomitan session continues using its external profile until the restart. While Hachidori is active, `--yomitan` opens the bundled Yomitan settings even if an external Yomitan profile is configured.
First-run setup remembers each backend that finished it, including when setup is reopened for legacy plugin cleanup. Switching to a backend for the first time asks for that backend's dictionaries; switching back to one that already finished does not repeat setup. Until SubMiner restarts, it keeps running the backend it started with, and the launcher gates playback on that running backend and logs a restart reminder. A running Yomitan session continues using its external profile until the restart. When `yomitan.externalProfilePath` is configured, `--yomitan` is disabled to preserve read-only external-profile mode, including while Hachidori is active. Hachidori settings remain available.
Hachidori's own duplicate handling differs from Yomitan's. Choosing **Overwrite** in the Hachidori popup updates the existing note and SubMiner enriches its media, while **Add anyway** creates a new note and runs SubMiner's Kiku/Senren [field grouping](./anki-integration.md#field-grouping-kiku-senren). Mining from the stats dashboard uses the selected backend as well.
+3 -1
View File
@@ -35,6 +35,8 @@ Update checks and startup launcher migration share a serialized update-state sto
- `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.
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
- `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
@@ -48,7 +50,7 @@ The dictionary backend is selected once at startup by `dictionaryBackend`. Yomit
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, and removes the unsupported `userScripts` permission only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), and removes the unsupported `userScripts` permission only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen popup auto-pause requires an unhidden popup pane in the host's shadow root and rechecks after each successful lookup. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
- Small units, explicit boundaries
- Composition over monoliths
+1
View File
@@ -21,6 +21,7 @@ Read when: you need to find the owner module for a behavior or test surface
`src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/`
+1 -2
View File
@@ -89,7 +89,6 @@ export async function jellyfinApiRequest<T>(
const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, {
headers: {
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`,
},
});
@@ -103,7 +102,7 @@ export async function jellyfinApiRequest<T>(
}
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`;
}
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
+3 -3
View File
@@ -228,7 +228,7 @@ export function pickLibrary(
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
@@ -266,7 +266,7 @@ export function pickItem(
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
@@ -304,7 +304,7 @@ export function pickGroup(
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.6",
"version": "0.20.0-beta.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -87,9 +87,9 @@
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.15.3",
"app-builder-lib": "26.16.1",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.6",
"form-data": "4.0.6",
"ip-address": "10.2.0",
@@ -131,7 +131,7 @@
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.15.3",
"electron-builder": "26.16.1",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
+122 -7
View File
@@ -91,6 +91,10 @@ function M.create(ctx)
end
local function key_code_to_mpv_name(code)
local first, second = code:match("^Key([A-Z])%-Key([A-Z])$")
if first and second then
return string.lower(first) .. "-" .. string.lower(second)
end
if KEY_NAME_MAP[code] then
return KEY_NAME_MAP[code]
end
@@ -187,6 +191,110 @@ function M.create(ctx)
return bindings
end
-- Match letter strokes, including mpv's uppercase spelling for Shift.
local function letter_key_signature(value)
if type(value) ~= "string" then
return nil
end
local modifiers = {}
while true do
local modifier, rest = value:match("^([%a]+)%+(.+)$")
if not modifier then
break
end
modifier = string.lower(modifier)
if not MODIFIER_MAP[modifier] then
return nil
end
modifiers[modifier] = true
value = rest
end
if not value:match("^[a-zA-Z]$") then
return nil
end
if value:match("^[A-Z]$") then
modifiers.shift = true
end
local parts = {}
for _, modifier in ipairs({ "ctrl", "alt", "shift", "meta" }) do
if modifiers[modifier] then
parts[#parts + 1] = modifier
end
end
parts[#parts + 1] = string.lower(value)
return table.concat(parts, "+")
end
local function external_single_keys()
local keys = {}
local native = mp.get_property_native and mp.get_property_native("input-bindings") or {}
for _, entry in ipairs(native or {}) do
local signature = letter_key_signature(entry.key)
if
signature
and type(entry.cmd) == "string"
and type(entry.priority) == "number"
and entry.priority >= 0
then
local owned = entry.owner == "subminer"
or (
entry.owner == nil
and (
entry.cmd:match("script%-binding%s+['\"]?subminer/")
or entry.cmd:match("script%-message%s+['\"]?subminer%-")
)
)
local previous = keys[signature]
if
not previous
or entry.priority > previous.priority
or (entry.priority == previous.priority and owned)
then
local command = entry.cmd:match("^%s*(.-)%s*$")
local flags = {
["no-osd"] = true,
["osd-bar"] = true,
["osd-msg"] = true,
["osd-msg-bar"] = true,
["osd-auto"] = true,
["expand-properties"] = true,
["raw"] = true,
["repeatable"] = true,
["nonrepeatable"] = true,
["nonscalable"] = true,
["async"] = true,
["sync"] = true,
}
while true do
local flag, rest = command:match("^(%S+)%s+(.+)$")
if not flags[flag] then
break
end
command = rest
end
keys[signature] = { priority = entry.priority, owned = owned, ignored = command == "ignore" }
end
end
end
return keys
end
local function sequence_conflict(binding, singles)
local code = binding.key and binding.key.code
local prefix = type(code) == "string" and code:match("^(Key[A-Z])%-Key[A-Z]$")
if not prefix then
return nil
end
local names = key_spec_to_mpv_bindings({ code = prefix, modifiers = binding.key.modifiers }) or {}
for _, name in ipairs(names) do
local existing = singles[letter_key_signature(name)]
if existing and not existing.owned and not existing.ignored then
return name
end
end
return nil
end
local function normalize_cli_args(cli_args)
if type(cli_args) ~= "table" then
return nil
@@ -391,17 +499,23 @@ function M.create(ctx)
local next_binding_names = {}
state.session_binding_generation = (state.session_binding_generation or 0) + 1
local generation = state.session_binding_generation
local singles = external_single_keys()
for index, binding in ipairs(artifact.bindings) do
if not is_supported_binding(binding) then
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported session binding from artifact"
)
subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
else
local key_names = key_spec_to_mpv_bindings(binding.key)
if key_names then
local conflict = sequence_conflict(binding, singles)
if conflict then
local message = "Disabled sequence "
.. tostring(binding.originalKey or binding.key.code)
.. ": mpv already uses "
.. conflict
.. ". Single-key bindings take priority."
subminer_log("warn", "session-bindings", message)
show_osd(message)
elseif key_names then
for key_index, key_name in ipairs(key_names) do
local name = "subminer-session-binding-"
.. tostring(generation)
@@ -418,7 +532,8 @@ function M.create(ctx)
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
"Skipped unsupported key code from artifact: "
.. tostring(binding.key and binding.key.code or "unknown")
)
end
end
+86 -47
View File
@@ -1,68 +1,107 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-base-version: 0.19.4 -->
<!-- prerelease-version: 0.20.0-beta.1 -->
## Highlights
### Added
- Library Merge & Reassignment
- Duplicate library cards for the same show can be combined: select entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card.
- Episodes can be moved to a different entry with a per-episode "→" button, fixing stray files that split off their own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair.
- Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
- **Japanese Subtitle Generation**:
- Generate Japanese subtitles locally with whisper.cpp, right from a modal (`Ctrl+Shift+G`), the subtitle sidebar's generation button when no subtitles are loaded, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv when it's done.
- Pick and download an official multilingual Whisper model in-app (including smaller quantized variants), or point Settings at one you already have. SubMiner recommends `large-v3-turbo` when CUDA is available and `small` otherwise, and tells you up front if `whisper-cli`, `ffmpeg`, or `ffprobe` can't be found.
- An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped.
- Long passages split near natural speech pauses, guided by an existing subtitle track when one is loaded, giving tighter timing and fewer repeated-word glitches.
- Duplicate Line Cleanup
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once confirmed.
- A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
- Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded.
- **Media Timing Review Frame Picker**:
- The screenshot used for a mined card can now be chosen independently of the audio clip, with its own live preview, time slider, and frame-by-frame stepping.
- Works for local video and for seekable remote streams like Jellyfin.
- **Overlay Keybinding Pickup**: The overlay now recognizes your mpv keybindings (from mpv's defaults, `input.conf`, and loaded scripts) as long as they don't conflict with SubMiner's own controls. Picked-up bindings work for the session but won't show up in the help menu.
- **Subtitle Sidebar Selection & Copy**: You can now select dialogue across multiple subtitle sidebar rows and copy it, without timestamps, using Ctrl/Cmd+C or the Copy button, without seeking or mining a card.
- **Jimaku Live Action Search**: The Jimaku modal has separate Anime and Live Action tabs (switch with Arrow Left/Right) so you can search Jimaku's live-action subtitle catalogue directly.
- **Live-Action TMDB Library**:
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
- Titles AniList can't match are looked up on TMDB automatically when the parsed filename matches a title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
- Release builds already include a TMDB key; if you run from source, set `tmdb.apiKey` (or `tmdb.apiKeyCommand`) yourself.
- **YouTube Library Kind**:
- YouTube channels are now their own Library media kind, with new All Titles, Anime, and YouTube filters. Existing channel entries migrate automatically with viewing history and manual video assignments intact.
- Channels stay out of AniList matching, season repair, and duplicate recommendations, and can't be merged or moved into an anime entry.
### Changed
- **Bundled Bun Runtime**: Every SubMiner launcher, installed or downloaded, now runs on the Bun runtime bundled with the app instead of a system-wide Bun install. Recognized legacy launchers migrate automatically, Windows users get a new `subminer.cmd` download, and first-run setup now shows a single optional launcher control with runtime repair guidance only when something actually needs it.
- **Compressed Incremental Sync**: Cross-machine sync between compatible macOS/Linux machines now transfers only what changed, compressed, using a cached snapshot from the last sync to cut traffic further. Machines without a compatible rsync (including Windows) fall back to compressed scp automatically, older peers keep working, and transfers now time out after 30 minutes instead of hanging indefinitely.
- **Smaller Install Size**: Installers and the unpacked app are smaller after dropping demo media, source maps, TypeScript sources, test fixtures, and unused binaries, and sharing one Japanese UI font across windows. Release builds now publish a package-size comparison against the previous release.
- **Stats Server Request Safety**: The stats server, including the in-app stats overlay which now loads through it, only accepts requests from the local machine and requires a JSON content type for anything that changes data. If you were exposing the dashboard through a reverse proxy or Tailscale Serve, that's no longer supported, and any script posting to the stats API needs to send `Content-Type: application/json`.
- **Yomitan Updated**: Bundled Yomitan is updated to upstream 26.9.8, adding historical Japanese kana transformations and Ukrainian language support, plus improvements to Anki duplicate search and audio retrieval.
### Fixed
- Subtitle Duplication from Karaoke & Animated Signs
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs.
- Fragmented karaoke now preserves the spaces the author placed between words instead of joining them together, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame.
- The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected.
- **Jellyfin**:
- Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled.
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
- The "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode.
- Anki cards mined from Jellyfin now get the real episode title in the misc info field instead of "Unknown media".
- Jellyfin streams no longer leak URL-derived titles or credential-bearing URLs into metadata, Anki fields, Discord presence, stats, or AniList lookups; previously cached data that had credentials in it is cleaned up automatically.
- Anki Media Generation
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`.
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished.
- **Anki & Mining**:
- Word audio now reads from its own configured field (`ankiConnect.fields.wordAudio`) instead of the sentence-audio field, fixing animated word images that started moving immediately instead of on demand.
- Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining.
- Closing the overlay while a media timing review is still loading now properly cancels setup, restores playback if the review had paused it, and cleans up the hidden preview player.
- Character Dictionary Performance & Notifications
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time.
- Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
- **Settings**:
- AnkiConnect, Kiku, and Senren settings are now validated before use, with a warning and a safe default for anything invalid instead of a bad value reaching runtime.
- Settings marked as applying live now correctly avoid showing a restart warning, and mixed saves apply the live parts immediately while listing only the sections that actually need a restart.
- Overlay Reliability
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems like Ventura instead of crashing and getting stuck on "Overlay loading."
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
- **Overlay**:
- Clicking a subtitle sidebar cue no longer leaves Space bound to seeking back to it; Enter still seeks the focused cue, and Space keeps whatever playback action you've configured.
- Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates.
- Fixed a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down.
- Stats Dashboard
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds.
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups.
- Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
- **Stats**:
- Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills.
- Stats server port conflicts now surface as a status notification instead of crashing SubMiner, and startup/shutdown are more robust: concurrent startup requests share one attempt, stopping a background instance no longer disconnects an open dashboard, and shutdown no longer waits indefinitely on active requests.
- Linux Launcher Thumbnails
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
- **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero.
- **First Launch on macOS**: Fixed first launch exiting immediately when the SubMiner config directory didn't exist yet.
## What's Changed
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
- build(release): reduce package size and report release sizes by @ksyasuda in #244
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
- fix(jellyfin): support modern authentication by @ksyasuda in #264
## New Contributors
- @aalhendi made their first contribution in #254
## Installation
+24 -10
View File
@@ -31,17 +31,31 @@ fs.rmSync(output, { recursive: true, force: true });
fs.mkdirSync(output, { recursive: true });
fs.cpSync(extension, output, { recursive: true });
// Host configuration belongs in the staged copy, leaving the fork usable in Chrome.
const overlayPath = path.join(output, 'overlay-mode.js');
let overlay = fs.readFileSync(overlayPath, 'utf8');
for (const [original, replacement] of [
['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'],
['customJavaScript: !IS_FIREFOX,', 'customJavaScript: false,'],
]) {
if (!overlay.includes(original))
throw new Error(`Hachidori host configuration changed upstream: ${original}`);
overlay = overlay.replace(original, replacement);
const hostConfiguration = {
'overlay-mode.js': [
['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'],
['customJavaScript: !IS_FIREFOX,', 'customJavaScript: false,'],
],
// Overlay hosts seed the lookup highlight off because their Anki screenshot is
// the see-through viewport. SubMiner captures media from mpv, and without the
// highlight the sidebar shows nothing for the word being looked up.
'setup-state.js': [
[
'lookupMode: "hover",\n sourceHighlightEnabled: false,',
'lookupMode: "hover",\n sourceHighlightEnabled: true,',
],
],
};
for (const [file, replacements] of Object.entries(hostConfiguration)) {
const filePath = path.join(output, file);
let text = fs.readFileSync(filePath, 'utf8');
for (const [original, replacement] of replacements) {
if (!text.includes(original))
throw new Error(`Hachidori host configuration changed upstream: ${original}`);
text = text.replace(original, replacement);
}
fs.writeFileSync(filePath, text);
}
fs.writeFileSync(overlayPath, overlay);
manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts');
fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) {
+9 -1
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
import test from 'node:test';
test('Hachidori staging configures Electron without changing the fork source', async () => {
const files = ['overlay-mode.js', 'manifest.json'];
const files = ['overlay-mode.js', 'setup-state.js', 'manifest.json'];
const source = (file: string) =>
new URL(`../vendor/hachidori/extension/${file}`, import.meta.url);
const before = files.map((file) => readFileSync(source(file), 'utf8'));
@@ -24,6 +24,14 @@ test('Hachidori staging configures Electron without changing the fork source', a
const original = await import(source('overlay-mode.js').href);
assert.equal(original.OVERLAY_MODE, false);
assert.equal(original.HOST_CAPABILITIES.customJavaScript, true);
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
// highlight stays on for a first install.
const stagedSetup = await import(
new URL('../build/hachidori/setup-state.js', import.meta.url).href
);
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
const originalSetup = await import(source('setup-state.js').href);
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
const manifest = JSON.parse(
readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'),
);
+28 -10
View File
@@ -7,6 +7,8 @@ if (process.platform !== 'linux')
throw new Error('This app-entry smoke requires Linux XDG isolation.');
const root = process.cwd();
const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori';
const externalYomitanProfile =
backend === 'hachidori' && !process.argv.includes('--bundled-yomitan');
const profile = process.env.SUBMINER_DICTIONARY_SMOKE_DATA;
assert(
profile && fs.existsSync(profile),
@@ -19,9 +21,9 @@ fs.writeFileSync(
path.join(profile, 'SubMiner', 'config.json'),
JSON.stringify({
dictionaryBackend: backend,
// An inactive external Yomitan profile must not block bundled settings in Hachidori mode.
// External Yomitan profiles must not block Hachidori settings.
yomitan: {
externalProfilePath: backend === 'hachidori' ? path.join(profile, 'external-yomitan') : '',
externalProfilePath: externalYomitanProfile ? path.join(profile, 'external-yomitan') : '',
},
mpv: { socketPath: path.join(profile, 'missing-mpv.sock') },
ankiConnect: { enabled: false },
@@ -76,16 +78,32 @@ function finish(exitCode) {
'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready',
);
app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root);
let yomi;
for (let i = 0; i < 200; i++) {
yomi = BrowserWindow.getAllWindows().find(
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
if (externalYomitanProfile) {
const blockedMessage =
'Yomitan settings unavailable while using read-only external-profile mode.';
for (let i = 0; i < 100; i++) {
if (fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage)) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(
fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage),
'--yomitan is rejected when an external Yomitan profile is configured',
);
if (yomi) break;
await new Promise((r) => setTimeout(r, 100));
assert.equal(session.defaultSession.extensions.getAllExtensions().length, 0);
assert.ok(!BrowserWindow.getAllWindows().some((w) => w.getTitle().includes('Yomitan')));
console.log('PASS external Yomitan settings blocked while Hachidori settings remain available');
} else {
let yomi;
for (let i = 0; i < 200; i++) {
yomi = BrowserWindow.getAllWindows().find(
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
);
if (yomi) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(yomi, 'inactive --yomitan settings opens');
assert.equal(yomi.webContents.session, session.defaultSession);
}
assert.ok(yomi, 'inactive --yomitan settings opens');
assert.equal(yomi.webContents.session, session.defaultSession);
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root);
let overlay;
+6 -3
View File
@@ -59,10 +59,13 @@ function listFiles(root, prefix = '') {
});
}
// asar resolves lookups with the platform separator, so stat with the listed
// native path and only normalize the reported name.
function listAppFiles(archive) {
return asar.listPackage(archive).flatMap((entry) => {
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
const stat = asar.statFile(archive, name);
const native = entry.replace(/^[\\/]/, '');
const stat = asar.statFile(archive, native);
const name = native.replaceAll('\\', '/');
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
});
}
@@ -137,7 +140,7 @@ function verifyContents(archive, resources, platform, arch) {
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
}
for (const ui of ['renderer', 'settings', 'syncui']) {
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString();
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
}
return entries;
+4 -1
View File
@@ -134,10 +134,12 @@ test('archive inventory handles native files without counting them twice on disk
mkdirSync(output);
writeFileSync(path.join(input, 'main.js'), 'hello');
writeFileSync(path.join(input, 'native.node'), 'native');
mkdirSync(path.join(input, 'dist', 'ai'), { recursive: true });
writeFileSync(path.join(input, 'dist', 'ai', 'client.js'), 'nested');
const archive = path.join(output, 'app.asar');
await createPackageFromStreams(
archive,
['main.js', 'native.node'].map((name) => ({
['main.js', 'native.node', 'dist/ai/client.js'].map((name) => ({
path: name,
type: 'file',
unpacked: name.endsWith('.node'),
@@ -148,6 +150,7 @@ test('archive inventory handles native files without counting them twice on disk
assert.deepEqual(listAppFiles(archive), [
{ path: 'main.js', bytes: 5 },
{ path: 'native.node', bytes: 6 },
{ path: 'dist/ai/client.js', bytes: 6 },
]);
assert.equal(
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
+6 -1
View File
@@ -14,7 +14,12 @@ delete env.ELECTRON_RUN_AS_NODE;
try {
const result = spawnSync(
require('electron'),
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)],
[
fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)),
path.resolve(resources),
// CI runners lack a setuid chrome-sandbox; this harness never loads remote content.
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
],
{ env, stdio: 'inherit', timeout: 75_000 },
);
if (result.error) throw result.error;
+12 -5
View File
@@ -1,11 +1,11 @@
// Run with the pinned Electron runtime against a finished app's resources folder.
const { app, BrowserWindow, session } = require('electron');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { createRequire } = require('node:module');
const assert = require('node:assert/strict');
const { once } = require('node:events');
const http = require('node:http');
const resources = path.resolve(process.argv[2]);
const archive = path.join(resources, 'app.asar');
@@ -55,10 +55,15 @@ async function smoke() {
.extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true });
assert(hachidori.id, 'Hachidori extension failed to load');
const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
if (details.error !== 'net::ERR_ABORTED')
failedRequests.push(`${details.url}: ${details.error}`);
});
session.defaultSession.webRequest.onErrorOccurred(
{ urls: ['file://*/*', 'http://127.0.0.1/*'] },
(details) => {
// Chromium probes the cache before fetching @font-face fonts; an uncached
// font reports ERR_CACHE_MISS and is then fetched normally.
if (!['net::ERR_ABORTED', 'net::ERR_CACHE_MISS'].includes(details.error))
failedRequests.push(`${details.url}: ${details.error}`);
},
);
for (const ui of ['renderer', 'settings', 'syncui']) {
const win = new BrowserWindow({
show: false,
@@ -99,6 +104,8 @@ async function smoke() {
if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`);
});
await statsWindow.loadURL(url);
// Let in-flight font requests settle before the window goes away.
await statsWindow.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
for (const endpoint of ['overview', 'sessions']) {
const response = await fetch(`${url}/api/stats/${endpoint}`);
assert.equal(response.status, 200, `Stats ${endpoint} request failed`);
+42 -9
View File
@@ -69,6 +69,12 @@ local ctx = {
return {
numericSelectionTimeoutMs = 3000,
bindings = {
{
key = { code = "KeyG-KeyS", modifiers = {} },
actionType = "session-action",
actionId = "openSubtitleSelection",
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
},
{
key = {
code = "KeyO",
@@ -312,7 +318,8 @@ local ctx = {
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
},
},
}, nil
},
nil
end,
},
state = {
@@ -430,17 +437,11 @@ assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
local character_dictionary_manager = find_binding("Ctrl+d")
assert_true(
character_dictionary_manager ~= nil,
"character dictionary manager binding should be registered"
)
assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
character_dictionary_manager.fn()
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
assert_true(
character_dictionary_manager_call ~= nil,
"character dictionary manager binding should invoke CLI action"
)
assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
assert_true(
character_dictionary_manager_call[2] == "--session-action",
"character dictionary manager binding should use generic session action CLI flag"
@@ -474,3 +475,35 @@ assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
print("plugin session binding regression tests: OK")
local selector = find_binding("g-s")
assert_true(selector ~= nil, "subtitle selection should override mpv g-s with a forced sequence")
selector.fn()
local selection_call = recorded.async_calls[#recorded.async_calls]
assert_true(
selection_call[3] == '{"actionId":"openSubtitleSelection"}',
"subtitle selection should dispatch its session action"
)
local native_bindings = {}
function mp.get_property_native(name)
assert_true(name == "input-bindings", "only native input bindings should be queried")
return native_bindings
end
for _, case in ipairs({
{ key = "g", priority = 1, enabled = false },
{ key = "G", priority = 1, enabled = true },
{ key = "Shift+g", priority = 1, enabled = true },
{ key = "Ctrl+g", priority = 1, enabled = true },
{ key = "g", priority = -1, enabled = true },
{ key = "g", priority = 1, cmd = "ignore", enabled = true },
{ key = "g", priority = 1, cmd = "no-osd ignore", enabled = true },
}) do
native_bindings = { { key = case.key, cmd = case.cmd or "show-text single", priority = case.priority } }
recorded.bindings = {}
assert_true(bindings.reload_bindings(), "binding reload should succeed")
assert_true((find_binding("g-s") ~= nil) == case.enabled, "sequence prefix conflict: " .. case.key)
end
assert_true(#recorded.osd > 0, "native prefix conflicts should be visible")
print("plugin sequence conflict tests: OK")
+22
View File
@@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
});
test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f (%t)' } } as never,
{} as never,
{
currentSubText: '',
currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
currentTimePos: 426,
currentSubStart: 426,
currentSubEnd: 428,
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
send: () => true,
} as unknown as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('ApiKey='), false);
});
+15 -1
View File
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=')) {
if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
return true;
}
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
@@ -243,6 +243,9 @@ export class AnkiIntegration {
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private generateSentenceFuriganaCallback:
| ((text: string, highlightedText?: string) => Promise<string | null>)
| null = null;
private mediaTimingReviewCallback:
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
| null = null;
@@ -666,6 +669,13 @@ export class AnkiIntegration {
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
processSentenceFurigana: (sentenceFurigana, noteFields) =>
this.processSentenceFurigana(sentenceFurigana, noteFields),
generateSentenceFurigana: async (text, noteFields) =>
this.generateSentenceFuriganaCallback?.(
text,
this.config.behavior?.highlightWord === false
? undefined
: this.getSentenceHighlightText(noteFields),
) ?? null,
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
@@ -1783,6 +1793,10 @@ export class AnkiIntegration {
this.consumeSubtitleMiningContextCallback = callback;
}
setSentenceFuriganaGenerator(callback: typeof this.generateSentenceFuriganaCallback): void {
this.generateSentenceFuriganaCallback = callback;
}
setMediaTimingReviewCallback(
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
): void {
@@ -188,13 +188,14 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness();
harness.deps.getCurrentSubtitleText = () => 'tokugi';
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'tokugi' },
Sentence: { value: '' },
Sentence: { value: 'tokugi' },
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
},
},
@@ -206,7 +207,7 @@ test('NoteUpdateWorkflow updates sentence furigana when highlight processor chan
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
Sentence: 'tokugi',
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
});
});
@@ -798,3 +799,63 @@ test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails',
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
assert.ok(harness.warnings.length === 0);
});
for (const outcome of ['success', 'unavailable', 'throws'] as const) {
test(`NoteUpdateWorkflow regenerates expanded furigana (${outcome})`, async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [
{
noteId: 42,
fields: {
Expression: { value: '猫' },
Sentence: { value: '<b>猫</b>を見た。' },
SentenceFurigana: { value: ' 猫[ねこ]を 見[み]た。' },
},
},
];
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: '猫を見た。',
startTime: 4,
endTime: 6,
});
harness.deps.reviewMediaTiming = async () => ({
action: 'confirm',
text: '猫を見た。犬もいた。',
startTime: 2,
endTime: 8,
});
harness.deps.generateSentenceFurigana = async (text, fields) => {
assert.equal(text, '猫を見た。犬もいた。');
assert.equal(fields.expression, '猫');
if (outcome === 'throws') throw new Error('parser unavailable');
return outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : null;
};
await harness.workflow.execute(42);
assert.equal(harness.updates[0]?.fields.Sentence, '猫を見た。犬もいた。');
assert.equal(
harness.updates[0]?.fields.SentenceFurigana,
outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : '',
);
});
}
test('NoteUpdateWorkflow preserves native furigana formatting when sentence context is unchanged', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [
{
noteId: 42,
fields: {
Expression: { value: '猫' },
Sentence: { value: '<b>猫</b>を見た。' },
SentenceFurigana: { value: '<ruby>猫<rt>ねこ</rt></ruby>を見た。' },
},
},
];
harness.deps.getCurrentSubtitleText = () => '猫を見た。';
harness.deps.generateSentenceFurigana = async () => {
assert.fail('unchanged sentence must keep native formatting');
};
await harness.workflow.execute(42);
assert.equal(harness.updates[0]?.fields.SentenceFurigana, undefined);
});
+25 -1
View File
@@ -76,6 +76,10 @@ export interface NoteUpdateWorkflowDeps {
sentenceFurigana: string,
noteFields: Record<string, string>,
) => string;
generateSentenceFurigana?: (
text: string,
noteFields: Record<string, string>,
) => Promise<string | null>;
setCardTypeFields: (
updatedFields: Record<string, string>,
availableFieldNames: string[],
@@ -288,7 +292,27 @@ export class NoteUpdateWorkflow {
const existingSentenceFurigana = sentenceFuriganaField
? noteInfo.fields[sentenceFuriganaField]?.value || ''
: '';
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
const sentenceChanged =
sentenceField &&
currentSubtitleText &&
normalizeSubtitleContextText(currentSubtitleText) !==
normalizeSubtitleContextText(noteInfo.fields[sentenceField]?.value ?? '');
if (sentenceFuriganaField && sentenceChanged) {
let furigana: string | null = null;
try {
furigana =
(await this.deps.generateSentenceFurigana?.(currentSubtitleText, fields)) ?? null;
} catch (error) {
this.deps.logWarn('Failed to regenerate sentence furigana:', error);
}
// Empty furigana lets card templates fall back to the updated Sentence field.
updatedFields[sentenceFuriganaField] = furigana ?? '';
updatePerformed = true;
} else if (
sentenceFuriganaField &&
existingSentenceFurigana &&
this.deps.processSentenceFurigana
) {
const processedSentenceFurigana = this.deps.processSentenceFurigana(
existingSentenceFurigana,
fields,
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatSentenceFurigana } from './sentence-furigana';
const parsed = [
{
source: 'scanning-parser',
content: [
[{ text: '猫', reading: 'ねこ' }],
[{ text: 'を' }],
[{ text: '見', reading: 'み' }, { text: 'た' }],
[{ text: '。\n' }],
[{ text: '犬', reading: 'いぬ' }],
[{ text: 'もいた。' }],
],
},
];
test('formats the complete expanded sentence with readings and the mined word highlighted', () => {
assert.equal(
formatSentenceFurigana('猫を見た。\n犬もいた。', parsed, '猫'),
'<b> 猫[ねこ]</b>を 見[み]た。\n 犬[いぬ]もいた。',
);
});
test('rejects headword-only and malformed parse results instead of saving partial furigana', () => {
assert.equal(
formatSentenceFurigana('猫を見た。', [
{ source: 'scanning-parser', content: [[{ text: '猫', reading: 'ねこ' }]] },
]),
null,
);
assert.equal(
formatSentenceFurigana('猫', [
{ source: 'scanning-parser', content: [[{ text: '猫', reading: 42 }]] },
]),
null,
);
});
test('escapes literal markup and annotation delimiters and leaves kana unannotated', () => {
const text = '<猫> [メモ]';
assert.equal(
formatSentenceFurigana(text, [
{
source: 'scanning-parser',
content: [
[{ text: '<' }],
[{ text: '猫', reading: 'ねこ' }],
[{ text: '> [' }],
[{ text: 'メモ', reading: 'めも' }],
[{ text: ']' }],
],
},
]),
'&lt; 猫[ねこ]&gt; &#91;メモ&#93;',
);
});
test('highlights the mined word without bolding the rest of a dictionary phrase', () => {
assert.equal(
formatSentenceFurigana(
'行儀を直して',
[
{
content: [
[
{ text: '行儀', reading: 'ぎょうぎ' },
{ text: 'を' },
{ text: '直', reading: 'なお' },
{ text: 'して' },
],
],
},
],
'行儀',
),
'<b> 行儀[ぎょうぎ]</b>を 直[なお]して',
);
});
+87
View File
@@ -0,0 +1,87 @@
type Segment = { text: string; reading?: string };
function readGroups(value: unknown): Segment[][] | null {
if (!Array.isArray(value)) return null;
const groups: Segment[][] = [];
const rawGroups: unknown[] = value;
for (const group of rawGroups) {
if (!Array.isArray(group)) return null;
const segments: Segment[] = [];
const rawSegments: unknown[] = group;
for (const segment of rawSegments) {
if (
typeof segment !== 'object' ||
segment === null ||
!('text' in segment) ||
typeof segment.text !== 'string' ||
('reading' in segment &&
segment.reading !== undefined &&
typeof segment.reading !== 'string')
)
return null;
segments.push({
text: segment.text,
reading:
'reading' in segment && typeof segment.reading === 'string' ? segment.reading : undefined,
});
}
groups.push(segments);
}
return groups;
}
function escapeText(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;');
}
// Only accept a complete parse, so a lookup of just the headword cannot replace sentence context.
export function formatSentenceFurigana(
text: string,
results: unknown[] | null,
highlightedText?: string,
): string | null {
for (const result of results ?? []) {
if (typeof result !== 'object' || result === null || !('content' in result)) continue;
const groups = readGroups(result.content);
if (
!groups ||
groups
.flat()
.map((segment) => segment.text)
.join('') !== text
)
continue;
const highlights: number[] = [];
if (highlightedText) {
let start = text.indexOf(highlightedText);
while (start >= 0) {
highlights.push(start);
start = text.indexOf(highlightedText, start + highlightedText.length);
}
}
let offset = 0;
let bold = false;
let output = '';
for (const { text: surface, reading } of groups.flat()) {
const start = offset;
offset += surface.length;
const highlighted = highlights.some(
(position) => position < offset && position + (highlightedText?.length ?? 0) > start,
);
if (highlighted !== bold) output += highlighted ? '<b>' : '</b>';
bold = highlighted;
const escaped = escapeText(surface);
output +=
reading && reading !== surface && /[\p{Script=Han}々]/u.test(surface)
? ` ${escaped}[${escapeText(reading)}]`
: escaped;
}
return output + (bold ? '</b>' : '');
}
return null;
}
+1
View File
@@ -58,6 +58,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSelection: { enabled: false },
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
dictionaryBackend,
hachidori,
+1
View File
@@ -103,6 +103,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T',
openSubtitleSelection: 'g-s',
openSubtitleGeneration: 'Ctrl+Shift+G',
openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C',
+7
View File
@@ -642,6 +642,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
},
{
path: 'shortcuts.openSubtitleSelection',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openSubtitleSelection,
description:
'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.',
},
{
path: 'shortcuts.openSubtitleGeneration',
kind: 'string',
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'subtitleSelection.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSelection.enabled,
description:
'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.',
},
...(
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
).map((key) => ({
@@ -17,6 +17,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Used only while Hachidori is linked to an external host.'],
key: 'hachidori',
},
{
title: 'Subtitle Selection',
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'],
key: 'subtitleSelection',
},
{
title: 'Japanese Subtitle Generation',
description: [
+7 -1
View File
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
const HOT_RELOAD_ROOTS = [
'subtitleStyle',
'keybindings',
'shortcuts',
'subtitleSidebar',
'subtitleSelection',
] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
+20
View File
@@ -33,6 +33,25 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
if (isObject(src.subtitleSelection)) {
const enabled = asBoolean(src.subtitleSelection.enabled);
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
else if (src.subtitleSelection.enabled !== undefined)
warn(
'subtitleSelection.enabled',
src.subtitleSelection.enabled,
resolved.subtitleSelection.enabled,
'Expected boolean.',
);
} else if (src.subtitleSelection !== undefined) {
warn(
'subtitleSelection',
src.subtitleSelection,
resolved.subtitleSelection,
'Expected object.',
);
}
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
if (launchAtStartup !== undefined) {
@@ -264,6 +283,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openRuntimeOptions',
'openJimaku',
'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration',
'openSessionHelp',
'openControllerSelect',
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { DEFAULT_CONFIG } from '../definitions';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import {
compileSessionBindings,
buildPluginSessionBindingsArtifact,
} from '../../core/services/session-bindings';
function bindings(config: ReturnType<typeof resolveConfig>['resolved']) {
return compileSessionBindings({
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
keybindings: config.keybindings,
platform: 'linux',
});
}
test('subtitle selection is opt-in and enabling it compiles g-s for mpv and the overlay', () => {
const defaults = resolveConfig({}).resolved;
assert.equal(defaults.subtitleSelection.enabled, false);
assert.equal(defaults.shortcuts.openSubtitleSelection, 'g-s');
const find = (config: typeof defaults) =>
bindings(config).bindings.find(
(binding) =>
binding.actionType === 'session-action' && binding.actionId === 'openSubtitleSelection',
);
assert.equal(find(defaults), undefined);
const enabled = resolveConfig({ subtitleSelection: { enabled: true } }).resolved;
const binding = find(enabled);
assert.ok(binding);
assert.deepEqual(binding.key, { code: 'KeyG-KeyS', modifiers: [] });
assert.equal(bindings(enabled).warnings.length, 0);
const artifact = buildPluginSessionBindingsArtifact({
bindings: [binding],
warnings: [],
numericSelectionTimeoutMs: 1000,
});
assert.deepEqual(artifact.bindings[0], {
...binding,
cliArgs: ['--session-action', '{"actionId":"openSubtitleSelection"}'],
});
enabled.subtitleSelection.enabled = false;
assert.equal(find(enabled), undefined);
});
test('subtitle selection settings are validated, hot reloadable, and the shortcut can be cleared', () => {
// @ts-expect-error Config files can contain invalid values at runtime.
const { resolved, warnings } = resolveConfig({ subtitleSelection: { enabled: 'yes' } });
assert.equal(resolved.subtitleSelection.enabled, false);
assert.equal(warnings.length, 1);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'subtitleSelection.enabled',
);
assert.equal(field?.category, 'behavior');
assert.equal(field?.restartBehavior, 'hot-reload');
const cleared = resolveConfig({
subtitleSelection: { enabled: true },
shortcuts: { openSubtitleSelection: null },
}).resolved;
assert.equal(resolveConfiguredShortcuts(cleared, DEFAULT_CONFIG).openSubtitleSelection, null);
});
+4
View File
@@ -459,6 +459,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subtitleSelection.')) {
return { category: 'behavior', section: 'Subtitle Selection' };
}
if (path.startsWith('subtitleGeneration.')) {
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
}
@@ -635,6 +638,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openTsukihime' ||
leaf === 'openSubtitleSelection' ||
leaf === 'openSubtitleGeneration' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
@@ -4323,3 +4323,101 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de
assert.equal(fetches, 1);
assert.deepEqual(assignments, [1]);
});
for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) {
it(`stats word mining updates full sentence furigana without media (${outcome})`, async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(
async (requests, url) => {
let calls = 0;
const app = createStatsApp(createMockTracker(), {
ankiConnectConfig: {
url,
deck: 'Mining',
media: { generateAudio: false, generateImage: false },
},
addYomitanNote: async () => 12345,
generateSentenceFurigana: async (text, word) => {
calls++;
assert.equal(text, '猫を見た。');
assert.equal(word, '猫');
if (outcome === 'throws') throw new Error('parser unavailable');
return outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。' : null;
},
});
const response = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1000,
endMs: 2000,
sentence: '猫を見た。',
word: '猫',
}),
});
assert.equal(response.status, 200);
const fields = requests.find((request) => request.action === 'updateNoteFields')?.params
?.note?.fields;
assert.equal(fields?.Sentence, '<b>猫</b>を見た。');
assert.equal(
fields?.sentencefurigana,
outcome === 'no-field'
? undefined
: outcome === 'success'
? '<b> 猫[ねこ]</b>を 見[み]た。'
: '',
);
assert.equal(calls, outcome === 'no-field' ? 0 : 1);
},
{
notesInfoFields: {
Sentence: { value: '猫' },
...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }),
},
},
);
});
});
}
it('stats word mining skips furigana highlighting when highlightWord is disabled', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(
async (_requests, url) => {
const highlights: Array<string | undefined> = [];
const app = createStatsApp(createMockTracker(), {
ankiConnectConfig: {
url,
deck: 'Mining',
media: { generateAudio: false, generateImage: false },
behavior: { highlightWord: false },
},
addYomitanNote: async () => 12345,
generateSentenceFurigana: async (_text, highlightedText) => {
highlights.push(highlightedText);
return ' 猫[ねこ]を 見[み]た。';
},
});
const response = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1000,
endMs: 2000,
sentence: '猫を見た。',
word: '猫',
}),
});
assert.equal(response.status, 200);
assert.deepEqual(highlights, [undefined]);
},
{ notesInfoFields: { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } } },
);
});
});
@@ -6,6 +6,7 @@ import test from 'node:test';
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
import { Database } from '../immersion-tracker/sqlite.js';
import {
applyPragmas,
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
@@ -50,6 +51,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-test.mkv', {
canonicalTitle: 'Cover Fetcher Test',
@@ -108,6 +110,7 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
canonicalTitle: 'Shared Cover Show',
@@ -191,6 +194,7 @@ function createJsonResponse(payload: unknown): Response {
test('fetchIfMissing uses guessit primary title and season when available', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', {
canonicalTitle:
@@ -296,6 +300,7 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
test('fetchIfMissing falls back to internal parser when guessit throws', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-fallback-test.mkv', {
canonicalTitle: 'School Vlog S01E01',
@@ -360,6 +365,7 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
@@ -438,6 +444,7 @@ test('fetchIfMissing caches a no-match when the season cannot be resolved', asyn
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
@@ -545,6 +552,7 @@ for (const linkedToAnilist of [false, true]) {
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
@@ -662,6 +670,7 @@ for (const linkedToAnilist of [false, true]) {
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
@@ -3174,6 +3174,7 @@ test('Jellyfin metadata cleanup requires both an API key and a stream marker', a
{ filename: 'stream?api_key=secret', leaked: true },
{ filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?api_key=secret', leaked: true },
{ filename: '/Videos/item?ApiKey=secret', leaked: true },
{ filename: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false },
@@ -376,6 +376,7 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
'api_key',
'ApiKey',
'StartTimeTicks',
'AudioStreamIndex',
'SubtitleStreamIndex',
@@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
) {
return null;
}
if (!url.searchParams.has('api_key')) {
if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) {
return null;
}
return url;
@@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str
SELECT v.canonical_title
FROM imm_videos v
WHERE v.anime_id = a.anime_id
AND v.canonical_title NOT LIKE '%api_key=%'
AND v.canonical_title NOT LIKE '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%'
AND lower(v.canonical_title) NOT LIKE '%api key%'
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
LIMIT 1
) AS linked_video_title
FROM imm_anime a
WHERE a.canonical_title LIKE '%api_key=%'
WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%'
OR lower(a.canonical_title) LIKE '%api key%'
OR lower(a.normalized_title_key) LIKE '%api key%'
`,
@@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata(
LAST_UPDATE_DATE = ?
WHERE source_type = 2
AND (
parsed_basename LIKE '%api_key=%'
parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%'
OR lower(parsed_basename) LIKE '%api key%'
OR parsed_title LIKE '%api_key=%'
OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%'
OR lower(parsed_title) LIKE '%api key%'
OR parse_metadata_json LIKE '%api_key=%'
OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%'
OR lower(parse_metadata_json) LIKE '%api key%'
)
`,
@@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata(
UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE (
metadata_json LIKE '%api_key=%'
metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%'
OR lower(metadata_json) LIKE '%api key%'
) AND (
lower(metadata_json) LIKE '%stream?%'
@@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
FROM imm_videos
WHERE source_type = 2
AND (
video_key LIKE '%api_key=%'
video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%'
OR lower(video_key) LIKE '%api key%'
OR source_url LIKE '%api_key=%'
OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%'
OR lower(source_url) LIKE '%api key%'
OR canonical_title LIKE '%api_key=%'
OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%'
OR lower(canonical_title) LIKE '%api key%'
)
`,
+2 -2
View File
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
+160 -2
View File
@@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je
class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
sent: string[] = [];
terminated = false;
send(data: string): void {
this.sent.push(data);
}
terminate(): void {
this.terminated = true;
this.emit('close');
}
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) {
@@ -58,7 +69,7 @@ test('start posts capabilities on socket connect', async () => {
accessToken: 'token-1',
deviceId: 'device-1',
webSocketFactory: (url) => {
assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1');
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
@@ -99,7 +110,8 @@ test('socket headers include jellyfin authorization metadata', () => {
assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false);
assert.equal('X-Emby-Token' in seenHeaders[0]!, false);
});
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
@@ -355,3 +367,149 @@ test('advertiseNow validates server registration using Sessions endpoint', async
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
});
test('answers ForceKeepAlive with KeepAlive messages on the advertised cadence', () => {
const sockets: FakeWebSocket[] = [];
const timers: Array<{ handler: () => void; delay: number }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-ka',
deviceId: 'device-ka',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
setTimer: ((handler: () => void, delay?: number) => {
timers.push({ handler, delay: Number(delay) });
return timers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('open');
assert.deepEqual(sockets[0]!.sent, ['{"MessageType":"KeepAlive"}']);
assert.equal(timers[0]!.delay, 30_000);
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 20 }));
assert.equal(sockets[0]!.sent.length, 2);
assert.equal(timers.at(-1)!.delay, 10_000);
timers.at(-1)!.handler();
assert.equal(sockets[0]!.sent.length, 3);
});
test('reconnects when the server stops answering keep-alives', () => {
let now = 1_000_000;
const sockets: FakeWebSocket[] = [];
const timers: Array<() => void> = [];
const warnings: string[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-lost',
deviceId: 'device-lost',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
getNow: () => now,
logWarn: (message) => {
warnings.push(message);
},
reconnectBaseDelayMs: 100,
setTimer: ((handler: () => void) => {
timers.push(handler);
return timers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('open');
// Two silent ticks are still within the 90s tolerance; the third marks the socket lost.
now += 30_000;
timers.shift()!();
now += 30_000;
timers.shift()!();
assert.equal(sockets[0]!.sent.length, 3);
assert.equal(sockets[0]!.terminated, false);
now += 30_000;
timers.shift()!();
assert.equal(sockets[0]!.terminated, true);
assert.equal(service.isConnected(), false);
assert.equal(warnings.length, 1);
timers.shift()!();
assert.equal(sockets.length, 2);
});
test('warns once per failing timeline endpoint until it recovers', async () => {
const warnings: string[] = [];
let status = 400;
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-warn',
deviceId: 'device-warn',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
logWarn: (message) => {
warnings.push(message);
},
});
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
assert.equal(await service.reportStopped(state), false);
assert.equal(await service.reportStopped(state), false);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
status = 200;
assert.equal(await service.reportStopped(state), true);
status = 500;
assert.equal(await service.reportStopped(state), false);
assert.equal(warnings.length, 2);
});
test('ignores messages from a superseded socket', () => {
const sockets: FakeWebSocket[] = [];
const playPayloads: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stale',
deviceId: 'device-stale',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
onPlay: (payload) => {
playPayloads.push(payload);
},
setTimer: (() => 1 as unknown as ReturnType<typeof setTimeout>) as unknown as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
service.stop();
service.start();
sockets[1]!.emit('open');
assert.equal(sockets.length, 2);
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 10 }));
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemIds: ['x'] } }));
assert.deepEqual(sockets[0]!.sent, []);
assert.deepEqual(playPayloads, []);
assert.deepEqual(sockets[1]!.sent, ['{"MessageType":"KeepAlive"}']);
});
+105 -10
View File
@@ -45,9 +45,22 @@ interface JellyfinRemoteSocket {
on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'message', listener: (data: unknown) => void): this;
send(data: string): void;
terminate?(): void;
close(): void;
}
// Jellyfin advertises its keep-alive timeout in the ForceKeepAlive message (60s by default),
// drops sockets that stay silent past it, and since 12.0 also detaches the session's remote
// controller when that happens. The drop never reaches the client as a close frame, so the
// client has to keep sending KeepAlive and treat missing replies as a dead connection.
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 60_000;
const KEEP_ALIVE_LOST_FACTOR = 1.5;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as unknown as { unref?: () => void }).unref?.();
}
type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions {
@@ -77,6 +90,9 @@ export interface JellyfinRemoteSessionServiceOptions {
deviceName?: string;
onConnected?: () => void;
onDisconnected?: () => void;
logWarn?: (message: string, details?: unknown) => void;
keepAliveTimeoutMs?: number;
getNow?: () => number;
}
function normalizeServerUrl(serverUrl: string): string {
@@ -196,6 +212,12 @@ export class JellyfinRemoteSessionService {
private readonly authHeader: string;
private readonly onConnected?: () => void;
private readonly onDisconnected?: () => void;
private readonly logWarn?: (message: string, details?: unknown) => void;
private readonly now: () => number;
private keepAliveTimeoutMs: number;
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
private lastInboundAtMs = 0;
private readonly failedRequestPaths = new Set<string>();
private readonly reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number;
@@ -233,6 +255,12 @@ export class JellyfinRemoteSessionService {
});
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.logWarn = options.logWarn;
this.now = options.getNow ?? Date.now;
this.keepAliveTimeoutMs = Math.max(
1000,
options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS,
);
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs,
@@ -250,6 +278,7 @@ export class JellyfinRemoteSessionService {
public stop(): void {
this.running = false;
this.connected = false;
this.stopKeepAlive();
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
@@ -298,12 +327,16 @@ export class JellyfinRemoteSessionService {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
this.lastInboundAtMs = this.now();
this.startKeepAlive(socket, this.keepAliveTimeoutMs);
this.onConnected?.();
void this.postCapabilities();
});
socket.on('message', (rawData) => {
this.handleInboundMessage(rawData);
if (this.socket !== socket || !this.running) return;
this.lastInboundAtMs = this.now();
this.handleInboundMessage(socket, rawData);
});
const handleDisconnect = () => {
@@ -311,6 +344,7 @@ export class JellyfinRemoteSessionService {
disconnected = true;
if (this.socket === socket) {
this.socket = null;
this.stopKeepAlive();
}
this.connected = false;
this.onDisconnected?.();
@@ -323,6 +357,51 @@ export class JellyfinRemoteSessionService {
socket.on('error', handleDisconnect);
}
private startKeepAlive(socket: JellyfinRemoteSocket, timeoutMs: number): void {
this.stopKeepAlive();
this.keepAliveTimeoutMs = timeoutMs;
this.sendKeepAlive(socket);
this.scheduleKeepAliveTick(socket);
}
private scheduleKeepAliveTick(socket: JellyfinRemoteSocket): void {
const intervalMs = Math.max(1000, Math.floor(this.keepAliveTimeoutMs / 2));
const timer = this.setTimer(() => {
this.keepAliveTimer = null;
if (this.socket !== socket || !this.running) return;
const silentForMs = this.now() - this.lastInboundAtMs;
if (silentForMs >= this.keepAliveTimeoutMs * KEEP_ALIVE_LOST_FACTOR) {
this.logWarn?.('Jellyfin remote websocket stopped answering keep-alives; reconnecting.');
// Dropping the socket raises 'close', which schedules the reconnect.
if (socket.terminate) {
socket.terminate();
} else {
socket.close();
}
return;
}
this.sendKeepAlive(socket);
this.scheduleKeepAliveTick(socket);
}, intervalMs);
unrefTimer(timer);
this.keepAliveTimer = timer;
}
private stopKeepAlive(): void {
if (this.keepAliveTimer) {
this.clearTimer(this.keepAliveTimer);
this.keepAliveTimer = null;
}
}
private sendKeepAlive(socket: JellyfinRemoteSocket): void {
try {
socket.send(JSON.stringify({ MessageType: 'KeepAlive' }));
} catch (error) {
this.logWarn?.('Failed to send Jellyfin remote keep-alive.', error);
}
}
private scheduleReconnect(): void {
const delay = Math.min(
this.reconnectMaxDelayMs,
@@ -342,7 +421,7 @@ export class JellyfinRemoteSessionService {
const baseUrl = new URL(`${this.serverUrl}/`);
const socketUrl = new URL('/socket', baseUrl);
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socketUrl.searchParams.set('api_key', this.accessToken);
socketUrl.searchParams.set('ApiKey', this.accessToken);
socketUrl.searchParams.set('deviceId', this.deviceId);
return socketUrl.toString();
}
@@ -350,8 +429,6 @@ export class JellyfinRemoteSessionService {
private createSocket(url: string): JellyfinRemoteSocket {
const headers: JellyfinRemoteSocketHeaders = {
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
};
if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers);
@@ -375,8 +452,6 @@ export class JellyfinRemoteSessionService {
method: 'GET',
headers: {
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
});
if (!response.ok) return false;
@@ -398,21 +473,41 @@ export class JellyfinRemoteSessionService {
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
body: JSON.stringify(payload),
});
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
return response.ok;
} catch {
} catch (error) {
this.noteRequestOutcome(path, error);
return false;
}
}
private handleInboundMessage(rawData: unknown): void {
// Warn once per path while it keeps failing so a rejected stop report is visible in the
// log without a warning per progress tick.
private noteRequestOutcome(path: string, failure: unknown): void {
if (failure === null) {
this.failedRequestPaths.delete(path);
return;
}
if (this.failedRequestPaths.has(path)) return;
this.failedRequestPaths.add(path);
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
}
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
const message = parseInboundMessage(rawData);
if (!message) return;
const messageType = message.MessageType;
if (messageType === 'ForceKeepAlive') {
const seconds = Number(message.Data);
const timeoutMs =
Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : this.keepAliveTimeoutMs;
this.startKeepAlive(socket, timeoutMs);
return;
}
if (messageType === 'KeepAlive') return;
const payload = parseMessageData(message.Data);
if (messageType === 'Play') {
this.onPlay?.(payload);
+70 -6
View File
@@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
assert.equal(url.searchParams.get('api_key'), 'token');
assert.equal(url.searchParams.get('ApiKey'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
} finally {
@@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
IsForced: true,
IsExternal: true,
DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token',
IsExternalUrl: false,
},
{
@@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
);
assert.equal(
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token',
);
assert.equal(
tracks[1]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token',
);
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
} finally {
@@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
assert.equal(url.searchParams.get('api_key'), 'token');
assert.equal(url.searchParams.get('ApiKey'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000');
@@ -626,7 +626,7 @@ test('listSubtitleTracks falls back from PlaybackInfo to item media sources', as
assert.equal(tracks[0]!.index, 11);
assert.equal(
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token',
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token',
);
} finally {
globalThis.fetch = originalFetch;
@@ -789,3 +789,67 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy
globalThis.fetch = originalFetch;
}
});
test('API requests authenticate with the MediaBrowser header only (no legacy X-Emby-Token)', async () => {
const originalFetch = globalThis.fetch;
const seenHeaders: Headers[] = [];
globalThis.fetch = (async (_input, init) => {
seenHeaders.push(new Headers(init?.headers));
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
}) as typeof fetch;
try {
await listLibraries(
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
clientInfo,
);
assert.equal(seenHeaders.length, 1);
const headers = seenHeaders[0]!;
const authorization = headers.get('authorization') ?? '';
assert.match(authorization, /^MediaBrowser /);
assert.match(authorization, /Token="token"/);
assert.match(authorization, /DeviceId="subminer-test"/);
assert.equal(headers.has('x-emby-token'), false);
assert.equal(headers.has('x-emby-authorization'), false);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan replaces a legacy api_key on the server transcoding url with ApiKey', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-legacy',
Name: 'Movie Legacy',
MediaSources: [
{
Id: 'ms-legacy',
Container: 'mkv',
SupportsDirectStream: false,
SupportsTranscoding: true,
TranscodingUrl: '/Videos/movie-legacy/master.m3u8?VideoCodec=hevc&api_key=server-token',
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
clientInfo,
{ enabled: true, directPlayPreferred: true },
{ itemId: 'movie-legacy' },
);
assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url);
assert.equal(url.searchParams.get('ApiKey'), 'token');
assert.equal(url.searchParams.has('api_key'), false);
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
} finally {
globalThis.fetch = originalFetch;
}
});
+15 -12
View File
@@ -136,6 +136,16 @@ function getErrorMessage(error: unknown): string {
return String(error || 'unknown error');
}
// Jellyfin reads query keys case-insensitively and older servers embed the token as
// `api_key` in the URLs they hand back, so drop every spelling before setting the one
// form Jellyfin 12 still accepts with legacy authorization disabled.
function setApiKeyParam(url: URL, accessToken: string): void {
for (const key of [...url.searchParams.keys()]) {
if (/^api_?key$/i.test(key)) url.searchParams.delete(key);
}
url.searchParams.set('ApiKey', accessToken);
}
function resolveDeliveryUrl(
session: JellyfinAuthSession,
stream: JellyfinMediaStream,
@@ -146,9 +156,7 @@ function resolveDeliveryUrl(
if (deliveryUrl) {
if (stream.IsExternalUrl === true) return deliveryUrl;
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
if (!resolved.searchParams.has('api_key')) {
resolved.searchParams.set('api_key', session.accessToken);
}
setApiKeyParam(resolved, session.accessToken);
return resolved.toString();
}
@@ -171,9 +179,7 @@ function resolveDeliveryUrl(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`,
);
if (!fallback.searchParams.has('api_key')) {
fallback.searchParams.set('api_key', session.accessToken);
}
setApiKeyParam(fallback, session.accessToken);
return fallback.toString();
}
@@ -197,7 +203,6 @@ async function jellyfinRequestJson<T>(
const headers = new Headers(init.headers ?? {});
headers.set('Content-Type', 'application/json');
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
headers.set('X-Emby-Token', session.accessToken);
const response = await fetch(`${session.serverUrl}${path}`, {
...init,
@@ -221,7 +226,7 @@ function createDirectPlayUrl(
): string {
const query = new URLSearchParams({
static: 'true',
api_key: session.accessToken,
ApiKey: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
});
if (mediaSource.LiveStreamId) {
@@ -245,9 +250,7 @@ function createTranscodeUrl(
): string {
if (mediaSource.TranscodingUrl) {
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
if (!url.searchParams.has('api_key')) {
url.searchParams.set('api_key', session.accessToken);
}
setApiKeyParam(url, session.accessToken);
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
@@ -261,7 +264,7 @@ function createTranscodeUrl(
}
const query = new URLSearchParams({
api_key: session.accessToken,
ApiKey: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
TranscodingContainer: 'ts',
+2
View File
@@ -1,5 +1,6 @@
import {
MPV_REQUEST_ID_AID,
MPV_REQUEST_ID_MEDIA_TITLE,
MPV_REQUEST_ID_OSD_DIMENSIONS,
MPV_REQUEST_ID_OSD_HEIGHT,
MPV_REQUEST_ID_PATH,
@@ -85,6 +86,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
},
{
command: ['get_property', 'media-title'],
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
},
{
command: ['get_property', 'pause'],
+23 -5
View File
@@ -35,6 +35,7 @@ export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
export const MPV_REQUEST_ID_PAUSE = 123;
export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
export const MPV_REQUEST_ID_MEDIA_TITLE = 202;
export type MpvMessageParser = (message: MpvMessage) => void;
export type MpvParseErrorHandler = (line: string, error: unknown) => void;
@@ -335,14 +336,18 @@ export async function dispatchMpvProtocolMessage(
} else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
deps.emitMediaTitleChange({
title,
});
applyMediaTitle(deps, msg.data);
} else if (msg.name === 'path') {
const path = (msg.data as string) || '';
deps.setCurrentVideoPath(path);
// A forced title set before loadfile arrives ahead of the path change that clears the
// cached title and never fires again, so read it back once the new path is known.
if (path) {
deps.sendCommand({
command: ['get_property', 'media-title'],
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
});
}
deps.emitMediaPathChange({ path });
deps.autoLoadSecondarySubTrack(path);
deps.syncCurrentAudioStreamIndex();
@@ -467,6 +472,8 @@ export async function dispatchMpvProtocolMessage(
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_PATH) {
deps.emitMediaPathChange({ path: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_MEDIA_TITLE) {
applyMediaTitle(deps, msg.data);
} else if (msg.request_id === MPV_REQUEST_ID_AID) {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex();
@@ -557,6 +564,17 @@ export function asFiniteNumber(value: unknown, fallback: number): number {
return Number.isFinite(nextValue) ? nextValue : fallback;
}
// URL-derived titles (mpv falls back to the basename of a query-bearing stream URL) must not
// replace known metadata, so they are dropped instead of cached.
function applyMediaTitle(
deps: Pick<MpvProtocolHandleMessageDeps, 'emitMediaTitleChange'>,
data: unknown,
): void {
const title = typeof data === 'string' ? sanitizeMediaTitle(data) : null;
if (typeof data === 'string' && data.trim() && !title) return;
deps.emitMediaTitleChange({ title });
}
export function parseVisibilityProperty(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return null;
+32 -3
View File
@@ -9,6 +9,7 @@ import {
} from './mpv';
import {
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_MEDIA_TITLE,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
} from './mpv-protocol';
@@ -135,9 +136,15 @@ test('MpvIpcClient ignores URL-derived titles without replacing known metadata',
assert.deepEqual(titles, ['My Anime S01E02']);
});
test('MpvIpcClient clears cached media title when media path changes', async () => {
test('MpvIpcClient clears cached media title when media path changes and reads it back', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const commands: Array<{ command?: unknown[]; request_id?: number }> = [];
(client as any).send = (command: { command?: unknown[]; request_id?: number }) => {
commands.push(command);
return true;
};
// A forced title (Jellyfin sets force-media-title before loadfile) arrives before the path.
await invokeHandleMessage(client, {
event: 'property-change',
name: 'media-title',
@@ -148,11 +155,33 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
await invokeHandleMessage(client, {
event: 'property-change',
name: 'path',
data: '/tmp/new-episode.mkv',
data: 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
});
assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv');
assert.equal(
client.currentVideoPath,
'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
);
assert.equal(client.currentMediaTitle, null);
const titleRequest = commands.find(
(command) => command.command?.[0] === 'get_property' && command.command?.[1] === 'media-title',
);
assert.equal(titleRequest?.request_id, MPV_REQUEST_ID_MEDIA_TITLE);
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
error: 'success',
data: '[Jellyfin/direct] Episode 1',
});
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
// A URL-derived read-back must not poison the cache.
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
error: 'success',
data: 'stream?static=true&ApiKey=secret',
});
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
});
test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openTsukihime: () => calls.push('tsukihime'),
openSubtitleSelection: () => calls.push('subtitle-selection'),
openSubtitleGeneration: () => calls.push('subtitle-generation'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
+4
View File
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
openControllerDebug: () => void;
openJimaku: () => void;
openTsukihime: () => void;
openSubtitleSelection: () => void;
openSubtitleGeneration: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
case 'openTsukihime':
deps.openTsukihime();
return;
case 'openSubtitleSelection':
deps.openSubtitleSelection();
return;
case 'openSubtitleGeneration':
deps.openSubtitleGeneration();
return;
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -707,3 +708,58 @@ test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-co
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
});
test('single keys reserve sequence prefixes without reserving the second stroke', () => {
for (const key of ['g', 'Ctrl+g', 's']) {
const result = compileSessionBindings({
shortcuts: createShortcuts({ openSubtitleSelection: 'g-s' }),
keybindings: [createKeybinding(key, ['show-text', 'single'])],
platform: 'linux',
});
assert.ok(result.bindings.some((binding) => binding.originalKey === key));
assert.equal(
result.bindings.some((binding) => binding.originalKey === 'g-s'),
key !== 'g',
);
assert.equal(result.warnings.length, key === 'g' ? 1 : 0);
if (key === 'g') assert.match(result.warnings[0]!.message, /Single-key bindings take priority/);
}
});
test('configured shortcuts and built-in overlay keys also reserve sequence prefixes', () => {
for (const prefix of ['g', 'y', 'v']) {
const result = compileSessionBindings({
shortcuts: createShortcuts({ openSubtitleSelection: `${prefix}-s`, copySubtitle: 'g' }),
keybindings: [],
platform: 'linux',
});
assert.equal(
result.bindings.some((binding) => binding.originalKey === `${prefix}-s`),
false,
);
assert.ok(result.bindings.some((binding) => binding.originalKey === 'g'));
assert.equal(result.warnings.length, 1);
}
});
test('sequence reservations follow the sidebar code and literal Shift semantics', () => {
for (const [toggleKey, disabled] of [
['KeyG', true],
['g', false],
['G', true],
] as const) {
const result = compileSessionBindings({
shortcuts: createShortcuts({ openSubtitleSelection: 'Shift+g-s' }),
keybindings: [],
platform: 'linux',
rawConfig: {
...DEFAULT_CONFIG,
subtitleSidebar: { ...DEFAULT_CONFIG.subtitleSidebar, toggleKey },
},
});
assert.equal(
result.bindings.some((binding) => binding.originalKey === 'Shift+g-s'),
!disabled,
);
}
});
+43 -1
View File
@@ -12,6 +12,10 @@ import type {
SessionKeySpec,
} from '../../types/session-bindings';
import { SPECIAL_COMMANDS } from '../../config';
import {
resolveSessionSequenceConflicts,
type SessionKeyReservation,
} from '../../shared/session-key-sequences';
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openTsukihime', actionId: 'openTsukihime' },
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
@@ -81,6 +86,13 @@ function normalizeCodeToken(
): string | null {
const normalized = token.trim();
if (!normalized) return null;
// Two lowercase letters use mpv's sequential-key syntax, for example g-s.
if (/^[a-z]-[a-z]$/.test(normalized)) {
return normalized
.split('-')
.map((letter) => `Key${letter.toUpperCase()}`)
.join('-');
}
if (options.allowMouseButtons === true) {
const normalizedMouse = normalized.toUpperCase();
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
@@ -543,7 +555,37 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
return { bindings, warnings };
const reservations: SessionKeyReservation[] = [
{ key: { code: 'KeyY', modifiers: [] }, path: 'built-in y sequences' },
{ key: { code: 'KeyV', modifiers: [] }, path: 'primary subtitle visibility key' },
{ key: { code: 'KeyY', modifiers: ['ctrl'] }, path: 'lookup window toggle' },
{ key: { code: 'KeyY', modifiers: ['meta'] }, path: 'lookup window toggle' },
{ key: { code: 'KeyY', modifiers: ['ctrl', 'shift'] }, path: 'keyboard-driven mode toggle' },
{ key: { code: 'KeyY', modifiers: ['shift', 'meta'] }, path: 'keyboard-driven mode toggle' },
...[...candidates.values()].flatMap((drafts) =>
drafts.map(({ binding }) => ({
key: binding.key,
path: binding.sourcePath,
})),
),
];
const sidebarKey = input.rawConfig?.subtitleSidebar?.toggleKey;
if (sidebarKey) {
const { key } = parseSessionBindingKey(sidebarKey, input.platform);
if (key) {
// The sidebar accepts DOM codes with either Shift state, or literal characters.
if (/^[A-Z]$/.test(sidebarKey)) key.modifiers = ['shift'];
reservations.push({ key, path: 'subtitleSidebar.toggleKey' });
if (/^Key[A-Z]$/.test(sidebarKey)) {
reservations.push({
key: { ...key, modifiers: ['shift'] },
path: 'subtitleSidebar.toggleKey',
});
}
}
}
const result = resolveSessionSequenceConflicts(bindings, reservations);
return { bindings: result.bindings, warnings: [...warnings, ...result.warnings] };
}
export function buildPluginSessionBindingsArtifact(input: {
+4
View File
@@ -1,3 +1,4 @@
import type { StatsMiningRouteOptions } from './stats-server/mining-support';
import { Hono } from 'hono';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
@@ -132,6 +133,7 @@ export interface StatsServerConfig {
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana'];
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
}
@@ -155,6 +157,7 @@ export function createStatsApp(
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana'];
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
createMediaGenerator?: () => StatsServerMediaGenerator;
@@ -191,6 +194,7 @@ export async function startStatsServerWithRuntime(
anilistRateLimiter: config.anilistRateLimiter,
tmdbClient: config.tmdbClient,
addYomitanNote: config.addYomitanNote,
generateSentenceFurigana: config.generateSentenceFurigana,
resolveAnkiNoteId: config.resolveAnkiNoteId,
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
});
+23 -14
View File
@@ -18,7 +18,6 @@ import {
getStatsDirectMiningAudioFieldNames,
getStatsWordMiningAudioFieldName,
resolveStatsNoteFieldName,
shouldUseStatsLapisKikuCardFields,
statsMiningLogger,
type StatsMiningRouteOptions,
type StatsServerNoteInfo,
@@ -229,19 +228,11 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
let noteInfo: StatsServerNoteInfo | null = null;
if (
audioBuffer ||
(syncAnimatedImageToWordAudio && generateImage) ||
shouldUseStatsLapisKikuCardFields(ankiConfig)
) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch (err) {
if (syncAnimatedImageToWordAudio && generateImage) {
errors.push(`image: ${(err as Error).message}`);
}
}
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch (error) {
errors.push(`note fields: ${error instanceof Error ? error.message : String(error)}`);
}
if (syncAnimatedImageToWordAudio && generateImage) {
try {
@@ -273,6 +264,24 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence;
const furiganaFieldName = noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceFurigana')
: null;
if (furiganaFieldName) {
let furigana: string | null = null;
try {
furigana =
(await options?.generateSentenceFurigana?.(
sentence,
ankiConfig.behavior?.highlightWord === false ? undefined : word,
)) ?? null;
} catch (error) {
statsMiningLogger.warn('Failed to generate sentence furigana:', error);
}
mediaFields[furiganaFieldName] = furigana ?? '';
if (furigana === null)
errors.push('furigana: unavailable; using the full sentence without readings');
}
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) {
@@ -39,6 +39,7 @@ export type StatsMiningRouteOptions = {
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
addYomitanNote?: (word: string) => Promise<number | null>;
generateSentenceFurigana?: (text: string, highlightedText?: string) => Promise<string | null>;
createMediaGenerator?: () => StatsServerMediaGenerator;
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
nowMs?: () => number;
@@ -36,14 +36,15 @@ async function harness(
vm.runInContext(
`
let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 };
let writes = 0, online = true, race = false, proxy = null;
let writes = 0, online = true, race = false, proxy = null, failWrites = false;
const readOptions = async () => structuredClone(options);
globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; };
const send = async (type, request, target) => {
if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request');
if (failWrites) throw Error('offline');
if (race) {
race = false;
options.anki.templates[0].deck = options.anki.deck = 'User edit';
options.anki.templates[0].tags = options.anki.tags = ['User edit'];
options.revision++;
}
if (request.baseRevision !== options.revision) throw Error('conflict');
@@ -101,7 +102,9 @@ test('fresh Hachidori settings inherit deck, tags, a unique model and configured
assert.equal(await h.run('writes'), 1);
});
test('preserves custom templates, tags, intentional blank fields and additional templates', async () => {
// 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 () => {
const h = await harness({
templates: [
{
@@ -118,9 +121,13 @@ test('preserves custom templates, tags, intentional blank fields and additional
{ id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] },
],
});
const before = await h.run('options.anki.templates');
const before = (await h.run('options.anki.templates')) as Array<Record<string, unknown>>;
await h.sync();
assert.deepEqual(await h.run('options.anki.templates'), before);
assert.deepEqual(await h.run('options.anki.templates'), [
{ ...before[0], deck: 'Mining' },
...before.slice(1),
]);
assert.equal(await h.run('options.anki.deck'), 'Mining');
});
test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => {
@@ -154,10 +161,17 @@ test('re-reads concurrent settings edits before retrying its revisioned write',
const h = await harness();
await h.run('race = true');
await h.sync();
assert.equal(await h.run('options.anki.deck'), 'User edit');
assert.deepEqual(await h.run('options.anki.tags'), ['User edit']);
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('restores the previous proxy marker when every settings write fails', async () => {
const h = await harness();
await h.run(`proxy = 'http://127.0.0.1:9000'; failWrites = true`);
await assert.rejects(h.sync(), /offline/);
assert.equal(await h.run('proxy'), 'http://127.0.0.1:9000');
});
test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => {
const hints = buildHachidoriAnkiHints({
fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' },
@@ -46,9 +46,9 @@ export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
const first = anki.templates[0];
if (!first) return { updated: false, matched: false, reason: 'no-templates' };
let template = { ...first, fields: { ...first.fields } };
const pristine = !first.model && first.fieldTemplates === null
&& Object.values(first.fields).every(value => !value);
if (deck && (!first.deck || (pristine && first.deck === 'Default'))) template.deck = deck;
// 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;
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
template.tags = [...hints.tags];
}
@@ -112,7 +112,11 @@ export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
}, 'hoshidicts-worker');
return { updated: changed, matched: !pending, pending };
} catch (error) {
if (attempt > 0) throw error;
if (attempt > 0) {
// The server was never written, so the marker must not claim it.
await globalThis.__subminerSetAnkiProxyUrl(previousProxy);
throw error;
}
// Re-read after a concurrent settings save before filling anything.
}
}
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateSentenceFurigana } from './sentence-furigana';
import { createDeps, runInjectedYomitanScript } from './yomitan-scan-test-harness';
test('generates sentence readings through the parser runtime bridge', async () => {
const requests: string[] = [];
const deps = createDeps((script) =>
runInjectedYomitanScript(script, (action, params) => {
requests.push(action);
if (action === 'optionsGetFull')
return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 20 } } }] };
assert.equal(action, 'parseText');
assert.ok(typeof params === 'object' && params !== null && 'text' in params);
assert.equal(params.text, '猫がいる。');
return [
{
source: 'scanning-parser',
content: [[{ text: '猫', reading: 'ねこ' }], [{ text: 'がいる。', reading: '' }]],
},
];
}),
);
assert.equal(
await generateSentenceFurigana('猫がいる。', '猫', deps, { error: assert.fail }),
'<b> 猫[ねこ]</b>がいる。',
);
assert.ok(requests.includes('parseText'));
});
test(
'a stalled parser cannot indefinitely block sentence and media updates',
{ timeout: 15_000 },
async () => {
const warnings: string[] = [];
const deps = createDeps(() => new Promise<never>(() => {}));
assert.equal(
await generateSentenceFurigana('猫', undefined, deps, {
error: () => undefined,
warn: (message) => warnings.push(message),
}),
null,
);
assert.equal(warnings.length, 1);
},
);
@@ -0,0 +1,28 @@
import { formatSentenceFurigana } from '../../../anki-integration/sentence-furigana';
import { requestYomitanParseResults } from './yomitan-parser-runtime';
export async function generateSentenceFurigana(
text: string,
highlightedText: string | undefined,
deps: Parameters<typeof requestYomitanParseResults>[1],
logger: Parameters<typeof requestYomitanParseResults>[2],
): Promise<string | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const results = await Promise.race([
requestYomitanParseResults(text, deps, logger),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('Sentence furigana generation timed out')),
10_000,
);
}),
]);
return formatSentenceFurigana(text, results, highlightedText);
} catch (error) {
logger.warn?.('Failed to generate sentence furigana:', error);
return null;
} finally {
clearTimeout(timer);
}
}
+5
View File
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openTsukihime: string | null | undefined;
openSubtitleSelection: string | null | undefined;
openSubtitleGeneration: string | null | undefined;
openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined;
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
openSubtitleSelection:
config.subtitleSelection?.enabled === true
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
: null,
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
+105 -48
View File
@@ -16,6 +16,7 @@ import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parse
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { generateSentenceFurigana } from './core/services/tokenizer/sentence-furigana';
import {
app,
BrowserWindow,
@@ -84,7 +85,6 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -131,11 +131,6 @@ import {
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from './shared/mpv-x11-backend';
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
@@ -402,6 +397,7 @@ import {
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -475,6 +471,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
import {
createSubtitleSelectionRuntime,
openSubtitleSelectionModal,
registerSubtitleSelectionIpc,
} from './main/runtime/subtitle-selection';
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
@@ -686,22 +687,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
/**
* Spawn a SubMiner-managed mpv (Jellyfin/YouTube) detached. On unsupported Wayland
* sessions it is pinned to XWayland — Wayland-hint env stripped and an X11 GPU context
* appended — so the XWayland overlay can stay above it, matching the `subminer` launcher.
*/
function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
if (!shouldForceX11WaylandSession(process.env)) {
return spawn('mpv', args, { detached: true, stdio: 'ignore' });
}
return spawn('mpv', [...args, ...MPV_X11_BACKEND_ARGS], {
detached: true,
stdio: 'ignore',
env: applyX11EnvOverrides({ ...process.env }),
});
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
@@ -2066,7 +2051,24 @@ function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolea
return command[0] === 'seek' || command[0] === 'sub-seek';
}
function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null {
if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') {
return null;
}
return command[2] === 'yes' || command[2] === 'no' ? command[2] : null;
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
// Overlay auto-pause releases (popup closed, hover left) must not resume playback
// behind an open timing review; the review applies them when it closes. A pause
// request during the review keeps playback paused after it closes.
const pauseValue = getMpvPauseCommandValue(rawCommand);
if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) {
return;
}
if (pauseValue === 'yes') {
mediaTimingReviewRuntime.cancelPlaybackResume();
}
const command =
resolveSanitizedSubtitleSeekCommand(
rawCommand,
@@ -3231,18 +3233,20 @@ const {
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () =>
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => configService.getConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () =>
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath,
mpvExecutablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
@@ -3250,7 +3254,7 @@ const {
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
},
spawnMpv: (args) => spawnManagedMpvProcess(args),
spawnMpv: spawnMpvProcess,
logWarn: (message, error) => logger.warn(message, error),
logInfo: (message) => logger.info(message),
},
@@ -4664,6 +4668,7 @@ const {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
void refreshMpvSessionBindings();
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -5225,6 +5230,13 @@ function createMainWindow(): BrowserWindow {
return window;
}
function generateMiningSentenceFurigana(
text: string,
highlightedText?: string,
): Promise<string | null> {
return generateSentenceFurigana(text, highlightedText, getYomitanParserRuntimeDeps(), logger);
}
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
@@ -5234,12 +5246,13 @@ function initializeOverlayRuntime(): void {
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana);
syncOverlayMpvSubtitleSuppression();
}
function openYomitanSettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
if (configService.getConfig().yomitan.externalProfilePath.trim()) {
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
return false;
}
@@ -5384,20 +5397,32 @@ const {
},
});
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => configService.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => logger.warn(message),
});
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => configService.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => logger.warn(message),
onBindingsChanged: (bindings) =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
onWarning: (warning) => {
if (warning.kind !== 'conflict') return;
overlayNotificationsRuntime.showOverlayNotification({
id: `session-binding-conflict:${warning.path}`,
title: 'Shortcut conflict',
body: warning.message,
variant: 'warning',
});
},
});
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
@@ -5652,6 +5677,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openJimaku: () => openJimakuOverlay(),
openTsukihime: () => openTsukihimeOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openSubtitleSelection: () => {
if (!configService.getConfig().subtitleSelection.enabled) return;
openOverlayHostedModalWithOsd(
openSubtitleSelectionModal,
'Subtitle selection overlay unavailable.',
'Failed to open subtitle selection overlay.',
);
},
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
@@ -6004,8 +6037,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getMpvInputBindings: () =>
readMpvInputBindings({
getMpvInputBindings: async () => {
await refreshMpvSessionBindings();
return readMpvInputBindings({
getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
platform:
@@ -6014,8 +6048,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
: process.platform === 'win32'
? 'win32'
: 'linux',
}),
getSessionBindings: () => appState.sessionBindings,
});
},
getSessionBindings: async () => {
await refreshMpvSessionBindings();
return appState.sessionBindings;
},
getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
@@ -6152,6 +6190,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration?.setMediaTimingReviewCallback(
mediaTimingReviewRuntime.requestReview,
);
appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana);
},
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
@@ -6598,10 +6637,17 @@ const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfil
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async (deps) => {
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
const extension = await hachidoriExtensionRuntime.ensureLoaded();
deps.setYomitanExtension(extension);
deps.setYomitanSession(getHachidoriSession());
return extension;
try {
const extension = await hachidoriExtensionRuntime.ensureLoaded();
deps.setYomitanExtension(extension);
deps.setYomitanSession(getHachidoriSession());
return extension;
} catch (error) {
logger.error('Failed to load Hachidori extension:', error);
deps.setYomitanExtension(null);
deps.setYomitanSession(null);
return null;
}
},
userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
@@ -6811,6 +6857,17 @@ function setOverlayVisible(visible: boolean): void {
}
registerIpcRuntimeHandlers();
registerSubtitleSelectionIpc({
ipc: ipcMain,
isAllowedSender: (sender) =>
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
(window) => window && !window.isDestroyed() && window.webContents === sender,
),
runtime: createSubtitleSelectionRuntime({
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
getMpvClient: () => appState.mpvClient,
}),
});
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>
@@ -7,6 +7,7 @@ import {
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin';
@@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers(
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
logWarn: options.logWarn,
});
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
buildReportJellyfinRemoteProgressMainDepsHandler(),
);
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
buildReportJellyfinRemoteStoppedMainDepsHandler(),
);
const reportTracker = createJellyfinRemoteReportTracker();
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
...buildReportJellyfinRemoteProgressMainDepsHandler(),
reportTracker,
});
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
reportTracker,
});
const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
if (diff.hotReloadFields.includes('shortcuts')) {
if (
diff.hotReloadFields.includes('shortcuts') ||
diff.hotReloadFields.includes('subtitleSelection')
) {
deps.refreshGlobalAndOverlayShortcuts();
}
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
+1 -1
View File
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
const mpvClient = deps.getMpvClient();
if (!connected || !mpvClient) {
throw new Error(
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
);
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more