Compare commits

..
Author SHA1 Message Date
sudacode ca673735ba feat(overlay): add optional subtitle selection modal
- Add primary and secondary mpv track selection from the overlay
- Support configurable sequence shortcuts with conflict detection and hot reload
- Document settings, shortcuts, and generated config defaults
2026-09-21 21:32:41 -07:00
99 changed files with 1577 additions and 822 deletions
+10 -10
View File
@@ -22,7 +22,7 @@
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
"electron-builder": "26.16.1", "electron-builder": "26.15.3",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -36,9 +36,9 @@
}, },
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.15", "@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.16.1", "app-builder-lib": "26.15.3",
"brace-expansion": "5.0.9", "brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.16.1", "electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.6", "fast-uri": "3.1.6",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
@@ -181,7 +181,7 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@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=="], "@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=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"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=="], "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=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "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=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"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": ["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-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], "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=="], "discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"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=="], "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=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "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": ["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.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": ["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-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-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-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-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-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=="], "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=="],
+2 -1
View File
@@ -1,4 +1,5 @@
type: docs type: docs
area: launcher area: launcher
- 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`). - 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.
+2 -1
View File
@@ -1,4 +1,5 @@
type: changed type: changed
area: launcher area: launcher
- 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. - 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.
+3 -1
View File
@@ -1,4 +1,6 @@
type: changed type: changed
area: sync area: sync
- 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. - 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.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: anki area: anki
- 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`. - 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`.
+4 -1
View File
@@ -1,4 +1,7 @@
type: fixed type: fixed
area: jellyfin area: jellyfin
- 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. - 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.
-7
View File
@@ -1,7 +0,0 @@
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".
+2 -1
View File
@@ -1,4 +1,5 @@
type: fixed type: fixed
area: stats area: stats
- 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. - 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.
+2 -1
View File
@@ -1,4 +1,5 @@
type: docs type: docs
area: subtitles area: subtitles
- Documented subtitle generation: model choice, VAD behavior, reference-track guided splitting, fallback behavior, and known limits. - 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.
+5 -4
View File
@@ -1,7 +1,8 @@
type: added type: added
area: subtitles area: subtitles
- 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. - 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.
- 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. - 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.
- 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). - 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.
- 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. - 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.
+2 -1
View File
@@ -1,4 +1,5 @@
type: docs type: docs
area: jellyfin area: jellyfin
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata. - 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.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added type: added
area: overlay area: overlay
- 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. - 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.
+2 -1
View File
@@ -1,4 +1,5 @@
type: changed type: changed
area: release area: release
- 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. - 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.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added type: added
area: overlay area: overlay
- 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. - 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.
+3 -1
View File
@@ -1,4 +1,6 @@
type: changed type: changed
breaking: true
area: stats area: stats
- 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. - 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.
+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 type: docs
area: stats area: stats
- Documented TMDB linking, provider reassignment, merge compatibility, and credential command caching. - Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown.
+5 -3
View File
@@ -1,6 +1,8 @@
type: added type: added
area: stats area: stats
- 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. - 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.
- 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. - 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.
- 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. - 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.
+1 -1
View File
@@ -1,4 +1,4 @@
type: docs type: docs
area: anki area: anki
- Documented the separate word-audio field mapping; existing animated images need regeneration to pick up the fix. - Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze.
+4 -2
View File
@@ -1,5 +1,7 @@
type: added type: added
area: stats area: stats
- YouTube channels are now a separate Library media kind. Existing channel entries migrate automatically without changing viewing history or manual video assignments. - 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.
- 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. - 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.
+10
View File
@@ -6,6 +6,15 @@
*/ */
{ {
// ==========================================
// 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 // Japanese Subtitle Generation
// Generate timed Japanese subtitles from local audio using whisper.cpp. // Generate timed Japanese subtitles from local audio using whisper.cpp.
@@ -223,6 +232,7 @@
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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). "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. "openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
+8
View File
@@ -1117,6 +1117,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> <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 ## External integrations
### Jimaku ### Jimaku
+1 -1
View File
@@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
## Requirements ## Requirements
- A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported) - A Jellyfin server plus your username and password
- SubMiner installed and running (see [Installation](/installation)) - SubMiner installed and running (see [Installation](/installation))
- On Linux, the session token is stored with `gnome-libsecret` by default - On Linux, the session token is stored with `gnome-libsecret` by default
+10
View File
@@ -6,6 +6,15 @@
*/ */
{ {
// ==========================================
// 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 // Japanese Subtitle Generation
// Generate timed Japanese subtitles from local audio using whisper.cpp. // Generate timed Japanese subtitles from local audio using whisper.cpp.
@@ -223,6 +232,7 @@
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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). "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. "openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "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+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | | `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `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+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) | | `\` | 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`. 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. 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. In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
+2
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. - `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 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. - `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/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `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. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
+2 -1
View File
@@ -89,6 +89,7 @@ export async function jellyfinApiRequest<T>(
const url = `${session.serverUrl}${requestPath}`; const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`, Authorization: `MediaBrowser Token="${session.accessToken}"`,
}, },
}); });
@@ -102,7 +103,7 @@ export async function jellyfinApiRequest<T>(
} }
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string { function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`; return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
} }
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string { function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
+3 -3
View File
@@ -228,7 +228,7 @@ export function pickLibrary(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)} url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${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 curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -266,7 +266,7 @@ export function pickItem(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)} url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${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 curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -304,7 +304,7 @@ export function pickGroup(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)} url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${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 curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.20.0-beta.1", "version": "0.19.6",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -85,9 +85,9 @@
}, },
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.15", "@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.16.1", "app-builder-lib": "26.15.3",
"brace-expansion": "5.0.9", "brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.16.1", "electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.6", "fast-uri": "3.1.6",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
@@ -129,7 +129,7 @@
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
"electron-builder": "26.16.1", "electron-builder": "26.15.3",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
+122 -7
View File
@@ -91,6 +91,10 @@ function M.create(ctx)
end end
local function key_code_to_mpv_name(code) 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 if KEY_NAME_MAP[code] then
return KEY_NAME_MAP[code] return KEY_NAME_MAP[code]
end end
@@ -187,6 +191,110 @@ function M.create(ctx)
return bindings return bindings
end 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) local function normalize_cli_args(cli_args)
if type(cli_args) ~= "table" then if type(cli_args) ~= "table" then
return nil return nil
@@ -391,17 +499,23 @@ function M.create(ctx)
local next_binding_names = {} local next_binding_names = {}
state.session_binding_generation = (state.session_binding_generation or 0) + 1 state.session_binding_generation = (state.session_binding_generation or 0) + 1
local generation = state.session_binding_generation local generation = state.session_binding_generation
local singles = external_single_keys()
for index, binding in ipairs(artifact.bindings) do for index, binding in ipairs(artifact.bindings) do
if not is_supported_binding(binding) then if not is_supported_binding(binding) then
subminer_log( subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
"warn",
"session-bindings",
"Skipped unsupported session binding from artifact"
)
else else
local key_names = key_spec_to_mpv_bindings(binding.key) 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 for key_index, key_name in ipairs(key_names) do
local name = "subminer-session-binding-" local name = "subminer-session-binding-"
.. tostring(generation) .. tostring(generation)
@@ -418,7 +532,8 @@ function M.create(ctx)
subminer_log( subminer_log(
"warn", "warn",
"session-bindings", "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
end end
+47 -86
View File
@@ -1,107 +1,68 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-version: 0.20.0-beta.1 --> <!-- prerelease-base-version: 0.19.4 -->
## Highlights ## Highlights
### Added ### Added
- **Japanese Subtitle Generation**: - Library Merge & Reassignment
- 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. - 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.
- 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. - 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.
- An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped. - Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
- 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.
- **Media Timing Review Frame Picker**: - Duplicate Line Cleanup
- 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. - 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.
- Works for local video and for seekable remote streams like Jellyfin. - 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.
- **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 ### Fixed
- **Jellyfin**: - Subtitle Duplication from Karaoke & Animated Signs
- 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. - 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.
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12. - 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 "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode. - The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected.
- Anki 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 & Mining**: - Anki Media Generation
- 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. - 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`.
- Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining. - 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.
- 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.
- **Settings**: - Character Dictionary Performance & Notifications
- 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. - 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.
- 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. - Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
- **Overlay**: - Overlay Reliability
- 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. - 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.
- Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates. - 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."
- Fixed a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down. - The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
- **Stats**: - Stats Dashboard
- Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills. - 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.
- 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. - 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).
- **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero. - Linux Launcher Thumbnails
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
- **First Launch on macOS**: Fixed first launch exiting immediately when the SubMiner config directory didn't exist yet.
## What's Changed ## What's Changed
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238 - feat(stats): add library entry merge and episode move by @ksyasuda in #190
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240 - fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241 - fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
- fix(startup): create config directory before singleton lock by @ksyasuda in #242 - fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243 - fix(anki): snapshot mining media clip timing by @ksyasuda in #197
- build(release): reduce package size and report release sizes by @ksyasuda in #244 - fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245 - fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246 - fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247 - fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250 - fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251 - fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252 - fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253 - fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254 - fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255 - fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256 - fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
- 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 ## Installation
+3 -6
View File
@@ -51,13 +51,10 @@ 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) { function listAppFiles(archive) {
return asar.listPackage(archive).flatMap((entry) => { return asar.listPackage(archive).flatMap((entry) => {
const native = entry.replace(/^[\\/]/, ''); const name = entry.replaceAll('\\', '/').replace(/^\//, '');
const stat = asar.statFile(archive, native); const stat = asar.statFile(archive, name);
const name = native.replaceAll('\\', '/');
return 'size' in stat ? [{ path: name, bytes: stat.size }] : []; return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
}); });
} }
@@ -132,7 +129,7 @@ function verifyContents(archive, resources, platform, arch) {
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`); assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
} }
for (const ui of ['renderer', 'settings', 'syncui']) { for (const ui of ['renderer', 'settings', 'syncui']) {
const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString(); const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`); assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
} }
return entries; return entries;
+1 -4
View File
@@ -134,12 +134,10 @@ test('archive inventory handles native files without counting them twice on disk
mkdirSync(output); mkdirSync(output);
writeFileSync(path.join(input, 'main.js'), 'hello'); writeFileSync(path.join(input, 'main.js'), 'hello');
writeFileSync(path.join(input, 'native.node'), 'native'); 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'); const archive = path.join(output, 'app.asar');
await createPackageFromStreams( await createPackageFromStreams(
archive, archive,
['main.js', 'native.node', 'dist/ai/client.js'].map((name) => ({ ['main.js', 'native.node'].map((name) => ({
path: name, path: name,
type: 'file', type: 'file',
unpacked: name.endsWith('.node'), unpacked: name.endsWith('.node'),
@@ -150,7 +148,6 @@ test('archive inventory handles native files without counting them twice on disk
assert.deepEqual(listAppFiles(archive), [ assert.deepEqual(listAppFiles(archive), [
{ path: 'main.js', bytes: 5 }, { path: 'main.js', bytes: 5 },
{ path: 'native.node', bytes: 6 }, { path: 'native.node', bytes: 6 },
{ path: 'dist/ai/client.js', bytes: 6 },
]); ]);
assert.equal( assert.equal(
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0), listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
+1 -6
View File
@@ -14,12 +14,7 @@ delete env.ELECTRON_RUN_AS_NODE;
try { try {
const result = spawnSync( const result = spawnSync(
require('electron'), 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 }, { env, stdio: 'inherit', timeout: 75_000 },
); );
if (result.error) throw result.error; if (result.error) throw result.error;
+8 -52
View File
@@ -1,7 +1,6 @@
// Run with the pinned Electron runtime against a finished app's resources folder. // Run with the pinned Electron runtime against a finished app's resources folder.
const { app, BrowserWindow, session } = require('electron'); const { app, BrowserWindow, session } = require('electron');
const fs = require('node:fs'); const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path'); const path = require('node:path');
const { createRequire } = require('node:module'); const { createRequire } = require('node:module');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -22,39 +21,6 @@ const timeout = setTimeout(() => {
app.exit(1); app.exit(1);
}, 60_000); }, 60_000);
const STATIC_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.json': 'application/json',
};
// The stats dashboard is served by the stats HTTP server in the app, so load it
// over loopback HTTP from the packaged stats/dist and treat missing static
// assets as failures. API routes are not part of this smoke and may 404.
function serveStatsDist(root, failedRequests) {
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
try {
const body = fs.readFileSync(path.join(root, relative));
res.writeHead(200, {
'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream',
});
res.end(body);
} catch {
if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`);
res.writeHead(404).end();
}
});
server.listen(0, '127.0.0.1');
return server;
}
async function smoke() { async function smoke() {
await app.whenReady(); await app.whenReady();
const packagedRequire = createRequire(path.join(archive, 'package.json')); const packagedRequire = createRequire(path.join(archive, 'package.json'));
@@ -84,17 +50,10 @@ async function smoke() {
); );
assert(extension.id, 'Yomitan extension failed to load'); assert(extension.id, 'Yomitan extension failed to load');
const failedRequests = []; const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred( session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
{ urls: ['file://*/*', 'http://127.0.0.1/*'] }, if (details.error !== 'net::ERR_ABORTED')
(details) => { failedRequests.push(`${details.url}: ${details.error}`);
// 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}`);
},
);
const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests);
await once(statsServer, 'listening');
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) { for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
const win = new BrowserWindow({ const win = new BrowserWindow({
show: false, show: false,
@@ -104,12 +63,10 @@ async function smoke() {
}, },
}); });
try { try {
if (ui === 'stats') { await win.loadFile(
await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`); path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
// Let in-flight font requests settle before the window goes away. );
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)'); if (ui !== 'stats') {
} else {
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
const loaded = await win.webContents.executeJavaScript( const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`, `document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
); );
@@ -119,7 +76,6 @@ async function smoke() {
win.destroy(); win.destroy();
} }
} }
statsServer.close();
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load'); assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
console.log( console.log(
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.', 'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
+42 -9
View File
@@ -69,6 +69,12 @@ local ctx = {
return { return {
numericSelectionTimeoutMs = 3000, numericSelectionTimeoutMs = 3000,
bindings = { bindings = {
{
key = { code = "KeyG-KeyS", modifiers = {} },
actionType = "session-action",
actionId = "openSubtitleSelection",
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
},
{ {
key = { key = {
code = "KeyO", code = "KeyO",
@@ -312,7 +318,8 @@ local ctx = {
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' }, cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
}, },
}, },
}, nil },
nil
end, end,
}, },
state = { 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") assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
local character_dictionary_manager = find_binding("Ctrl+d") local character_dictionary_manager = find_binding("Ctrl+d")
assert_true( assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
character_dictionary_manager ~= nil,
"character dictionary manager binding should be registered"
)
character_dictionary_manager.fn() character_dictionary_manager.fn()
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls] local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
assert_true( assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
character_dictionary_manager_call ~= nil,
"character dictionary manager binding should invoke CLI action"
)
assert_true( assert_true(
character_dictionary_manager_call[2] == "--session-action", character_dictionary_manager_call[2] == "--session-action",
"character dictionary manager binding should use generic session action CLI flag" "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") assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
print("plugin session binding regression tests: OK") 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,25 +1545,3 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426); const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)'); 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);
});
+1 -1
View File
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean { function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase(); const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase(); const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) { if (loweredPath.includes('api_key=')) {
return true; return true;
} }
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) { if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
+1
View File
@@ -56,6 +56,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = { export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSelection: { enabled: false },
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG }, subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
subtitlePosition, subtitlePosition,
keybindings, keybindings,
+1
View File
@@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openRuntimeOptions: 'CommandOrControl+Shift+O', openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J', openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T', openTsukihime: 'Ctrl+Shift+T',
openSubtitleSelection: 'g-s',
openSubtitleGeneration: 'Ctrl+Shift+G', openSubtitleGeneration: 'Ctrl+Shift+G',
openSessionHelp: 'CommandOrControl+Slash', openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C', openControllerSelect: 'Alt+C',
+7
View File
@@ -628,6 +628,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openSessionHelp, defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.', 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', path: 'shortcuts.openSubtitleGeneration',
kind: 'string', kind: 'string',
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig, defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] { ): ConfigOptionRegistryEntry[] {
return [ 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 ['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
).map((key) => ({ ).map((key) => ({
@@ -1,6 +1,12 @@
import { ConfigTemplateSection } from './shared'; import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
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', title: 'Japanese Subtitle Generation',
description: [ description: [
+7 -1
View File
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`); 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 = [ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode', 'secondarySub.defaultMode',
+20
View File
@@ -6,6 +6,25 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void { export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; const { src, resolved, warn } = context;
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)) { if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
if (launchAtStartup !== undefined) { if (launchAtStartup !== undefined) {
@@ -237,6 +256,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openRuntimeOptions', 'openRuntimeOptions',
'openJimaku', 'openJimaku',
'openTsukihime', 'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration', 'openSubtitleGeneration',
'openSessionHelp', 'openSessionHelp',
'openControllerSelect', '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
@@ -455,6 +455,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subsync.')) { if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) }; return { category: 'integrations', section: topSection(path) };
} }
if (path.startsWith('subtitleSelection.')) {
return { category: 'behavior', section: 'Subtitle Selection' };
}
if (path.startsWith('subtitleGeneration.')) { if (path.startsWith('subtitleGeneration.')) {
return { category: 'integrations', section: 'Japanese Subtitle Generation' }; return { category: 'integrations', section: 'Japanese Subtitle Generation' };
} }
@@ -631,6 +634,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openRuntimeOptions' || leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' || leaf === 'openJimaku' ||
leaf === 'openTsukihime' || leaf === 'openTsukihime' ||
leaf === 'openSubtitleSelection' ||
leaf === 'openSubtitleGeneration' || leaf === 'openSubtitleGeneration' ||
leaf === 'openSessionHelp' || leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' || leaf === 'openControllerSelect' ||
@@ -3174,7 +3174,6 @@ 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: '/STREAM?API_KEY=secret', leaked: true }, { filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?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: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false }, { filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false }, { filename: 'api_key=ordinary-metadata', leaked: false },
@@ -376,7 +376,6 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [ const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
'api_key', 'api_key',
'ApiKey',
'StartTimeTicks', 'StartTimeTicks',
'AudioStreamIndex', 'AudioStreamIndex',
'SubtitleStreamIndex', 'SubtitleStreamIndex',
@@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
) { ) {
return null; return null;
} }
if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) { if (!url.searchParams.has('api_key')) {
return null; return null;
} }
return url; return url;
@@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str
SELECT v.canonical_title SELECT v.canonical_title
FROM imm_videos v FROM imm_videos v
WHERE v.anime_id = a.anime_id WHERE v.anime_id = a.anime_id
AND v.canonical_title NOT LIKE '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%' AND v.canonical_title NOT LIKE '%api_key=%'
AND lower(v.canonical_title) NOT LIKE '%api key%' AND lower(v.canonical_title) NOT LIKE '%api key%'
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
LIMIT 1 LIMIT 1
) AS linked_video_title ) AS linked_video_title
FROM imm_anime a FROM imm_anime a
WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%' WHERE a.canonical_title LIKE '%api_key=%'
OR lower(a.canonical_title) LIKE '%api key%' OR lower(a.canonical_title) LIKE '%api key%'
OR lower(a.normalized_title_key) LIKE '%api key%' OR lower(a.normalized_title_key) LIKE '%api key%'
`, `,
@@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata(
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE source_type = 2 WHERE source_type = 2
AND ( AND (
parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%' parsed_basename LIKE '%api_key=%'
OR lower(parsed_basename) LIKE '%api key%' OR lower(parsed_basename) LIKE '%api key%'
OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%' OR parsed_title LIKE '%api_key=%'
OR lower(parsed_title) LIKE '%api key%' OR lower(parsed_title) LIKE '%api key%'
OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%' OR parse_metadata_json LIKE '%api_key=%'
OR lower(parse_metadata_json) LIKE '%api key%' OR lower(parse_metadata_json) LIKE '%api key%'
) )
`, `,
@@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata(
UPDATE imm_anime UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ? SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE ( WHERE (
metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%' metadata_json LIKE '%api_key=%'
OR lower(metadata_json) LIKE '%api key%' OR lower(metadata_json) LIKE '%api key%'
) AND ( ) AND (
lower(metadata_json) LIKE '%stream?%' lower(metadata_json) LIKE '%stream?%'
@@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
FROM imm_videos FROM imm_videos
WHERE source_type = 2 WHERE source_type = 2
AND ( AND (
video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%' video_key LIKE '%api_key=%'
OR lower(video_key) LIKE '%api key%' OR lower(video_key) LIKE '%api key%'
OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%' OR source_url LIKE '%api_key=%'
OR lower(source_url) LIKE '%api key%' OR lower(source_url) LIKE '%api key%'
OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%' OR canonical_title LIKE '%api_key=%'
OR lower(canonical_title) LIKE '%api key%' OR lower(canonical_title) LIKE '%api key%'
) )
`, `,
+2 -2
View File
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>; getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[]; getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>; getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[]; getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
+2 -160
View File
@@ -4,17 +4,6 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je
class FakeWebSocket { class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {}; 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 { on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) { if (!this.listeners[event]) {
@@ -69,7 +58,7 @@ test('start posts capabilities on socket connect', async () => {
accessToken: 'token-1', accessToken: 'token-1',
deviceId: 'device-1', deviceId: 'device-1',
webSocketFactory: (url) => { webSocketFactory: (url) => {
assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1'); assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
const socket = new FakeWebSocket(); const socket = new FakeWebSocket();
sockets.push(socket); sockets.push(socket);
return socket as unknown as any; return socket as unknown as any;
@@ -110,8 +99,7 @@ test('socket headers include jellyfin authorization metadata', () => {
assert.equal(seenHeaders.length, 1); assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"')); assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"')); assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false); assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
assert.equal('X-Emby-Token' in seenHeaders[0]!, false);
}); });
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => { test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
@@ -367,149 +355,3 @@ test('advertiseNow validates server registration using Sessions endpoint', async
assert.equal(ok, true); assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith('/Sessions'))); 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"}']);
});
+10 -105
View File
@@ -45,22 +45,9 @@ interface JellyfinRemoteSocket {
on(event: 'close', listener: () => void): this; on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this; on(event: 'error', listener: (error: Error) => void): this;
on(event: 'message', listener: (data: unknown) => void): this; on(event: 'message', listener: (data: unknown) => void): this;
send(data: string): void;
terminate?(): void;
close(): 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>; type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions { export interface JellyfinRemoteSessionServiceOptions {
@@ -90,9 +77,6 @@ export interface JellyfinRemoteSessionServiceOptions {
deviceName?: string; deviceName?: string;
onConnected?: () => void; onConnected?: () => void;
onDisconnected?: () => void; onDisconnected?: () => void;
logWarn?: (message: string, details?: unknown) => void;
keepAliveTimeoutMs?: number;
getNow?: () => number;
} }
function normalizeServerUrl(serverUrl: string): string { function normalizeServerUrl(serverUrl: string): string {
@@ -212,12 +196,6 @@ export class JellyfinRemoteSessionService {
private readonly authHeader: string; private readonly authHeader: string;
private readonly onConnected?: () => void; private readonly onConnected?: () => void;
private readonly onDisconnected?: () => 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 reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number; private readonly reconnectMaxDelayMs: number;
@@ -255,12 +233,6 @@ export class JellyfinRemoteSessionService {
}); });
this.onConnected = options.onConnected; this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected; 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.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max( this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs, this.reconnectBaseDelayMs,
@@ -278,7 +250,6 @@ export class JellyfinRemoteSessionService {
public stop(): void { public stop(): void {
this.running = false; this.running = false;
this.connected = false; this.connected = false;
this.stopKeepAlive();
if (this.reconnectTimer) { if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer); this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null; this.reconnectTimer = null;
@@ -327,16 +298,12 @@ export class JellyfinRemoteSessionService {
if (this.socket !== socket || !this.running) return; if (this.socket !== socket || !this.running) return;
this.connected = true; this.connected = true;
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.lastInboundAtMs = this.now();
this.startKeepAlive(socket, this.keepAliveTimeoutMs);
this.onConnected?.(); this.onConnected?.();
void this.postCapabilities(); void this.postCapabilities();
}); });
socket.on('message', (rawData) => { socket.on('message', (rawData) => {
if (this.socket !== socket || !this.running) return; this.handleInboundMessage(rawData);
this.lastInboundAtMs = this.now();
this.handleInboundMessage(socket, rawData);
}); });
const handleDisconnect = () => { const handleDisconnect = () => {
@@ -344,7 +311,6 @@ export class JellyfinRemoteSessionService {
disconnected = true; disconnected = true;
if (this.socket === socket) { if (this.socket === socket) {
this.socket = null; this.socket = null;
this.stopKeepAlive();
} }
this.connected = false; this.connected = false;
this.onDisconnected?.(); this.onDisconnected?.();
@@ -357,51 +323,6 @@ export class JellyfinRemoteSessionService {
socket.on('error', handleDisconnect); 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 { private scheduleReconnect(): void {
const delay = Math.min( const delay = Math.min(
this.reconnectMaxDelayMs, this.reconnectMaxDelayMs,
@@ -421,7 +342,7 @@ export class JellyfinRemoteSessionService {
const baseUrl = new URL(`${this.serverUrl}/`); const baseUrl = new URL(`${this.serverUrl}/`);
const socketUrl = new URL('/socket', baseUrl); const socketUrl = new URL('/socket', baseUrl);
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:'; socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socketUrl.searchParams.set('ApiKey', this.accessToken); socketUrl.searchParams.set('api_key', this.accessToken);
socketUrl.searchParams.set('deviceId', this.deviceId); socketUrl.searchParams.set('deviceId', this.deviceId);
return socketUrl.toString(); return socketUrl.toString();
} }
@@ -429,6 +350,8 @@ export class JellyfinRemoteSessionService {
private createSocket(url: string): JellyfinRemoteSocket { private createSocket(url: string): JellyfinRemoteSocket {
const headers: JellyfinRemoteSocketHeaders = { const headers: JellyfinRemoteSocketHeaders = {
Authorization: this.authHeader, Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
}; };
if (this.socketHeadersFactory) { if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers); return this.socketHeadersFactory(url, headers);
@@ -452,6 +375,8 @@ export class JellyfinRemoteSessionService {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: this.authHeader, Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
}, },
}); });
if (!response.ok) return false; if (!response.ok) return false;
@@ -473,41 +398,21 @@ export class JellyfinRemoteSessionService {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: this.authHeader, Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
return response.ok; return response.ok;
} catch (error) { } catch {
this.noteRequestOutcome(path, error);
return false; return false;
} }
} }
// Warn once per path while it keeps failing so a rejected stop report is visible in the private handleInboundMessage(rawData: unknown): void {
// 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); const message = parseInboundMessage(rawData);
if (!message) return; if (!message) return;
const messageType = message.MessageType; 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); const payload = parseMessageData(message.Data);
if (messageType === 'Play') { if (messageType === 'Play') {
this.onPlay?.(payload); this.onPlay?.(payload);
+6 -70
View File
@@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
assert.equal(plan.mode, 'transcode'); assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url); const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/); assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
assert.equal(url.searchParams.get('ApiKey'), 'token'); assert.equal(url.searchParams.get('api_key'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '4'); assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000'); assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
} finally { } finally {
@@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
IsForced: true, IsForced: true,
IsExternal: true, IsExternal: true,
DeliveryMethod: 'External', DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token', DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
IsExternalUrl: false, IsExternalUrl: false,
}, },
{ {
@@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
); );
assert.equal( assert.equal(
tracks[0]!.deliveryUrl, tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token', 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
); );
assert.equal( assert.equal(
tracks[1]!.deliveryUrl, tracks[1]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token', 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
); );
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt'); assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
} finally { } finally {
@@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para
const url = new URL(plan.url); const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/); assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
assert.equal(url.searchParams.get('VideoCodec'), 'hevc'); assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
assert.equal(url.searchParams.get('ApiKey'), 'token'); assert.equal(url.searchParams.get('api_key'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '3'); assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8'); assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000'); 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]!.index, 11);
assert.equal( assert.equal(
tracks[0]!.deliveryUrl, tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token', 'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token',
); );
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
@@ -789,67 +789,3 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy
globalThis.fetch = originalFetch; 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;
}
});
+12 -15
View File
@@ -136,16 +136,6 @@ function getErrorMessage(error: unknown): string {
return String(error || 'unknown error'); 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( function resolveDeliveryUrl(
session: JellyfinAuthSession, session: JellyfinAuthSession,
stream: JellyfinMediaStream, stream: JellyfinMediaStream,
@@ -156,7 +146,9 @@ function resolveDeliveryUrl(
if (deliveryUrl) { if (deliveryUrl) {
if (stream.IsExternalUrl === true) return deliveryUrl; if (stream.IsExternalUrl === true) return deliveryUrl;
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`); const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
setApiKeyParam(resolved, session.accessToken); if (!resolved.searchParams.has('api_key')) {
resolved.searchParams.set('api_key', session.accessToken);
}
return resolved.toString(); return resolved.toString();
} }
@@ -179,7 +171,9 @@ function resolveDeliveryUrl(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`, `/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`, `${session.serverUrl}/`,
); );
setApiKeyParam(fallback, session.accessToken); if (!fallback.searchParams.has('api_key')) {
fallback.searchParams.set('api_key', session.accessToken);
}
return fallback.toString(); return fallback.toString();
} }
@@ -203,6 +197,7 @@ async function jellyfinRequestJson<T>(
const headers = new Headers(init.headers ?? {}); const headers = new Headers(init.headers ?? {});
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken)); headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
headers.set('X-Emby-Token', session.accessToken);
const response = await fetch(`${session.serverUrl}${path}`, { const response = await fetch(`${session.serverUrl}${path}`, {
...init, ...init,
@@ -226,7 +221,7 @@ function createDirectPlayUrl(
): string { ): string {
const query = new URLSearchParams({ const query = new URLSearchParams({
static: 'true', static: 'true',
ApiKey: session.accessToken, api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id), MediaSourceId: ensureString(mediaSource.Id),
}); });
if (mediaSource.LiveStreamId) { if (mediaSource.LiveStreamId) {
@@ -250,7 +245,9 @@ function createTranscodeUrl(
): string { ): string {
if (mediaSource.TranscodingUrl) { if (mediaSource.TranscodingUrl) {
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`); const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
setApiKeyParam(url, session.accessToken); if (!url.searchParams.has('api_key')) {
url.searchParams.set('api_key', session.accessToken);
}
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) { if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex)); url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
} }
@@ -264,7 +261,7 @@ function createTranscodeUrl(
} }
const query = new URLSearchParams({ const query = new URLSearchParams({
ApiKey: session.accessToken, api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id), MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'), VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
TranscodingContainer: 'ts', TranscodingContainer: 'ts',
-2
View File
@@ -1,6 +1,5 @@
import { import {
MPV_REQUEST_ID_AID, MPV_REQUEST_ID_AID,
MPV_REQUEST_ID_MEDIA_TITLE,
MPV_REQUEST_ID_OSD_DIMENSIONS, MPV_REQUEST_ID_OSD_DIMENSIONS,
MPV_REQUEST_ID_OSD_HEIGHT, MPV_REQUEST_ID_OSD_HEIGHT,
MPV_REQUEST_ID_PATH, MPV_REQUEST_ID_PATH,
@@ -86,7 +85,6 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
}, },
{ {
command: ['get_property', 'media-title'], command: ['get_property', 'media-title'],
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
}, },
{ {
command: ['get_property', 'pause'], command: ['get_property', 'pause'],
+5 -23
View File
@@ -35,7 +35,6 @@ export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
export const MPV_REQUEST_ID_PAUSE = 123; export const MPV_REQUEST_ID_PAUSE = 123;
export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; 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 MpvMessageParser = (message: MpvMessage) => void;
export type MpvParseErrorHandler = (line: string, error: unknown) => void; export type MpvParseErrorHandler = (line: string, error: unknown) => void;
@@ -336,18 +335,14 @@ export async function dispatchMpvProtocolMessage(
} else if (msg.name === 'fullscreen') { } else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) }); deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') { } else if (msg.name === 'media-title') {
applyMediaTitle(deps, msg.data); const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
deps.emitMediaTitleChange({
title,
});
} else if (msg.name === 'path') { } else if (msg.name === 'path') {
const path = (msg.data as string) || ''; const path = (msg.data as string) || '';
deps.setCurrentVideoPath(path); 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.emitMediaPathChange({ path });
deps.autoLoadSecondarySubTrack(path); deps.autoLoadSecondarySubTrack(path);
deps.syncCurrentAudioStreamIndex(); deps.syncCurrentAudioStreamIndex();
@@ -472,8 +467,6 @@ export async function dispatchMpvProtocolMessage(
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_PATH) { } else if (msg.request_id === MPV_REQUEST_ID_PATH) {
deps.emitMediaPathChange({ path: (msg.data as string) || '' }); 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) { } else if (msg.request_id === MPV_REQUEST_ID_AID) {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex(); deps.syncCurrentAudioStreamIndex();
@@ -564,17 +557,6 @@ export function asFiniteNumber(value: unknown, fallback: number): number {
return Number.isFinite(nextValue) ? nextValue : fallback; 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 { export function parseVisibilityProperty(value: unknown): boolean | null {
if (typeof value === 'boolean') return value; if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
+3 -32
View File
@@ -9,7 +9,6 @@ import {
} from './mpv'; } from './mpv';
import { import {
MPV_REQUEST_ID_TRACK_LIST_AUDIO, MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_MEDIA_TITLE,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY, MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
} from './mpv-protocol'; } from './mpv-protocol';
@@ -136,15 +135,9 @@ test('MpvIpcClient ignores URL-derived titles without replacing known metadata',
assert.deepEqual(titles, ['My Anime S01E02']); assert.deepEqual(titles, ['My Anime S01E02']);
}); });
test('MpvIpcClient clears cached media title when media path changes and reads it back', async () => { test('MpvIpcClient clears cached media title when media path changes', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); 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, { await invokeHandleMessage(client, {
event: 'property-change', event: 'property-change',
name: 'media-title', name: 'media-title',
@@ -155,33 +148,11 @@ test('MpvIpcClient clears cached media title when media path changes and reads i
await invokeHandleMessage(client, { await invokeHandleMessage(client, {
event: 'property-change', event: 'property-change',
name: 'path', name: 'path',
data: 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret', data: '/tmp/new-episode.mkv',
}); });
assert.equal( assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv');
client.currentVideoPath,
'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
);
assert.equal(client.currentMediaTitle, null); 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 () => { test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openTsukihime: null, openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null, openSubtitleGeneration: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openTsukihime: null, openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null, openSubtitleGeneration: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerDebug: () => calls.push('controller-debug'), openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'), openJimaku: () => calls.push('jimaku'),
openTsukihime: () => calls.push('tsukihime'), openTsukihime: () => calls.push('tsukihime'),
openSubtitleSelection: () => calls.push('subtitle-selection'),
openSubtitleGeneration: () => calls.push('subtitle-generation'), openSubtitleGeneration: () => calls.push('subtitle-generation'),
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube'); calls.push('youtube');
+4
View File
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
openControllerDebug: () => void; openControllerDebug: () => void;
openJimaku: () => void; openJimaku: () => void;
openTsukihime: () => void; openTsukihime: () => void;
openSubtitleSelection: () => void;
openSubtitleGeneration: () => void; openSubtitleGeneration: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>; openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
case 'openTsukihime': case 'openTsukihime':
deps.openTsukihime(); deps.openTsukihime();
return; return;
case 'openSubtitleSelection':
deps.openSubtitleSelection();
return;
case 'openSubtitleGeneration': case 'openSubtitleGeneration':
deps.openSubtitleGeneration(); deps.openSubtitleGeneration();
return; return;
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openTsukihime: null, openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null, openSubtitleGeneration: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: 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('copySubtitleMultiple')?.cliArgs, undefined);
assert.equal(byActionId.get('mineSentenceMultiple')?.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, SessionKeySpec,
} from '../../types/session-bindings'; } from '../../types/session-bindings';
import { SPECIAL_COMMANDS } from '../../config'; import { SPECIAL_COMMANDS } from '../../config';
import {
resolveSessionSequenceConflicts,
type SessionKeyReservation,
} from '../../shared/session-key-sequences';
type PlatformKeyModel = 'darwin' | 'win32' | 'linux'; type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' }, { key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openTsukihime', actionId: 'openTsukihime' }, { key: 'openTsukihime', actionId: 'openTsukihime' },
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' }, { key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' }, { key: 'openControllerSelect', actionId: 'openControllerSelect' },
@@ -81,6 +86,13 @@ function normalizeCodeToken(
): string | null { ): string | null {
const normalized = token.trim(); const normalized = token.trim();
if (!normalized) return null; 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) { if (options.allowMouseButtons === true) {
const normalizedMouse = normalized.toUpperCase(); const normalizedMouse = normalized.toUpperCase();
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) { 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)); 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: { export function buildPluginSessionBindingsArtifact(input: {
+5
View File
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
openRuntimeOptions: string | null | undefined; openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined; openJimaku: string | null | undefined;
openTsukihime: string | null | undefined; openTsukihime: string | null | undefined;
openSubtitleSelection: string | null | undefined;
openSubtitleGeneration: string | null | undefined; openSubtitleGeneration: string | null | undefined;
openSessionHelp: string | null | undefined; openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined; openControllerSelect: string | null | undefined;
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')), openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')), openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')), openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
openSubtitleSelection:
config.subtitleSelection?.enabled === true
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
: null,
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')), openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')), openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')), openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
+60 -18
View File
@@ -466,6 +466,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime'; import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc'; 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 { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services'; import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
@@ -4583,6 +4588,7 @@ const {
maybeStartOverlayLoadingOsd(); maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications(); flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0); secondarySubtitleTrackController.scheduleRefresh(0);
void refreshMpvSessionBindings();
if (appState.sessionBindingsInitialized) { if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [ sendMpvCommandRuntime(appState.mpvClient, [
'script-message', 'script-message',
@@ -5235,20 +5241,32 @@ const {
}, },
}); });
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({ const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
configDir: CONFIG_DIR, createSessionBindingsRuntime({
getKeybindings: () => appState.keybindings, configDir: CONFIG_DIR,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getKeybindings: () => appState.keybindings,
getResolvedConfig: () => configService.getConfig(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
getMpvClient: () => appState.mpvClient, getResolvedConfig: () => configService.getConfig(),
setSessionBindings: (bindings) => { getMpvClient: () => appState.mpvClient,
appState.sessionBindings = bindings; setSessionBindings: (bindings) => {
}, appState.sessionBindings = bindings;
setSessionBindingsInitialized: (initialized) => { },
appState.sessionBindingsInitialized = initialized; setSessionBindingsInitialized: (initialized) => {
}, appState.sessionBindingsInitialized = initialized;
logWarn: (message) => logger.warn(message), },
}); 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({ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: { appendToMpvLogMainDeps: {
@@ -5503,6 +5521,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openJimaku: () => openJimakuOverlay(), openJimaku: () => openJimakuOverlay(),
openTsukihime: () => openTsukihimeOverlay(), openTsukihime: () => openTsukihimeOverlay(),
openSessionHelp: () => openSessionHelpOverlay(), openSessionHelp: () => openSessionHelpOverlay(),
openSubtitleSelection: () => {
if (!configService.getConfig().subtitleSelection.enabled) return;
openOverlayHostedModalWithOsd(
openSubtitleSelectionModal,
'Subtitle selection overlay unavailable.',
'Failed to open subtitle selection overlay.',
);
},
openSubtitleGeneration: () => openSubtitleGenerationOverlay(), openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(), openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(), openControllerSelect: () => openControllerSelectOverlay(),
@@ -5855,8 +5881,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position), saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getMpvInputBindings: () => getMpvInputBindings: async () => {
readMpvInputBindings({ await refreshMpvSessionBindings();
return readMpvInputBindings({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [], getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
platform: platform:
@@ -5865,8 +5892,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
: process.platform === 'win32' : process.platform === 'win32'
? 'win32' ? 'win32'
: 'linux', : 'linux',
}), });
getSessionBindings: () => appState.sessionBindings, },
getSessionBindings: async () => {
await refreshMpvSessionBindings();
return appState.sessionBindings;
},
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request), dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => configService.getConfig().stats.toggleKey, getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
@@ -6649,6 +6680,17 @@ function setOverlayVisible(visible: boolean): void {
} }
registerIpcRuntimeHandlers(); 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({ const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration, getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () => getModelDirectory: () =>
@@ -7,7 +7,6 @@ import {
createHandleJellyfinRemoteGeneralCommand, createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay, createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate, createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler, createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin'; } from '../domains/jellyfin';
@@ -92,17 +91,13 @@ export function composeJellyfinRemoteHandlers(
getNow: options.getNow, getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond, ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug, logDebug: options.logDebug,
logWarn: options.logWarn,
}); });
const reportTracker = createJellyfinRemoteReportTracker(); const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({ buildReportJellyfinRemoteProgressMainDepsHandler(),
...buildReportJellyfinRemoteProgressMainDepsHandler(), );
reportTracker, const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
}); buildReportJellyfinRemoteStoppedMainDepsHandler(),
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({ );
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
reportTracker,
});
const buildHandleJellyfinRemotePlayMainDepsHandler = const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({ createBuildHandleJellyfinRemotePlayMainDepsHandler({
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.setKeybindings(payload.keybindings); deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings); deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
if (diff.hotReloadFields.includes('shortcuts')) { if (
diff.hotReloadFields.includes('shortcuts') ||
diff.hotReloadFields.includes('subtitleSelection')
) {
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();
} }
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openTsukihime: null, openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null, openSubtitleGeneration: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openTsukihime: null, openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null, openSubtitleGeneration: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
@@ -75,6 +75,5 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined, getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond, ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error), logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
}); });
} }
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
markJellyfinRemotePlaybackLoaded, markJellyfinRemotePlaybackLoaded,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler, createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks, secondsToJellyfinTicks,
@@ -529,70 +528,3 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
assert.equal(stopped, false); assert.equal(stopped, false);
assert.equal(cleared, false); assert.equal(cleared, false);
}); });
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
const tracker = createJellyfinRemoteReportTracker();
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
itemId: 'item-1',
playMethod: 'DirectPlay',
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
};
const calls: string[] = [];
let releaseProgress: () => void = () => undefined;
const progressGate = new Promise<void>((resolve) => {
releaseProgress = resolve;
});
const session = {
isConnected: () => true,
reportProgress: async ({ eventName }: { eventName: string }) => {
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
if (calls.length === 1) await progressGate;
return true;
},
reportStopped: async () => {
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
return true;
},
};
const shared = {
getActivePlayback: () => playback,
clearActivePlayback: () => {
playback = null;
},
getSession: () => session,
getMpvClient: () => ({ currentTimePos: 42 }),
ticksPerSecond: 10_000_000,
logDebug: () => undefined,
reportTracker: tracker,
};
const reportProgress = createReportJellyfinRemoteProgressHandler({
...shared,
getNow: () => 10_000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => undefined,
progressIntervalMs: 3000,
});
const reportStopped = createReportJellyfinRemoteStoppedHandler(shared);
// A periodic tick is mid-request when the stop starts.
const tick = reportProgress(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
const stop = reportStopped();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(playback, null);
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
// A tick fired after the stop began must not report anything.
await reportProgress(true);
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
releaseProgress();
await tick;
await stop;
assert.deepEqual(calls, [
'progress:TimeUpdate:active',
'progress:TimeUpdate:cleared',
'stopped:cleared',
]);
});
+4 -43
View File
@@ -134,29 +134,6 @@ function isSeekLikePositionJump(
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds; return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
} }
// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress
// tick that lands after the stop report leaves the server showing playback forever. The
// tracker lets the stop handler wait for reports that are already in flight.
export type JellyfinRemoteReportTracker = {
track: (report: Promise<void>) => void;
settled: () => Promise<void>;
};
export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker {
const active = new Set<Promise<void>>();
return {
track: (report) => {
active.add(report);
void report.finally(() => active.delete(report));
},
settled: async () => {
while (active.size > 0) {
await Promise.allSettled([...active]);
}
},
};
}
export type JellyfinRemoteProgressReporterDeps = { export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void; clearActivePlayback: () => void;
@@ -168,7 +145,6 @@ export type JellyfinRemoteProgressReporterDeps = {
progressIntervalMs: number; progressIntervalMs: number;
ticksPerSecond: number; ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
reportTracker?: JellyfinRemoteReportTracker;
}; };
export function createReportJellyfinRemoteProgressHandler( export function createReportJellyfinRemoteProgressHandler(
@@ -176,7 +152,7 @@ export function createReportJellyfinRemoteProgressHandler(
) { ) {
let lastReportedPositionSeconds: number | null = null; let lastReportedPositionSeconds: number | null = null;
const report = async (force: boolean): Promise<void> => { return async (force = false): Promise<void> => {
const playback = deps.getActivePlayback(); const playback = deps.getActivePlayback();
if (!playback) return; if (!playback) return;
const session = deps.getSession(); const session = deps.getSession();
@@ -217,12 +193,6 @@ export function createReportJellyfinRemoteProgressHandler(
deps.logDebug('Failed to report Jellyfin remote progress', error); deps.logDebug('Failed to report Jellyfin remote progress', error);
} }
}; };
return async (force = false): Promise<void> => {
const pending = report(force);
deps.reportTracker?.track(pending);
await pending;
};
} }
export type JellyfinRemoteStoppedReporterDeps = { export type JellyfinRemoteStoppedReporterDeps = {
@@ -233,8 +203,6 @@ export type JellyfinRemoteStoppedReporterDeps = {
getNow?: () => number; getNow?: () => number;
ticksPerSecond: number; ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
logWarn?: (message: string) => void;
reportTracker?: JellyfinRemoteReportTracker;
}; };
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) { export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
@@ -258,10 +226,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
deps.clearActivePlayback(); deps.clearActivePlayback();
return; return;
} }
// Clear before any network call so progress ticks fired during the stop find nothing to
// report, then let reports already in flight finish so none can arrive after the stop.
deps.clearActivePlayback();
await deps.reportTracker?.settled();
try { try {
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient()); const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
@@ -280,7 +244,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
} catch (error) { } catch (error) {
deps.logDebug('Failed to report Jellyfin remote final progress', error); deps.logDebug('Failed to report Jellyfin remote final progress', error);
} }
const reported = await session.reportStopped({ await session.reportStopped({
itemId: playback.itemId, itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId, mediaSourceId: playback.mediaSourceId,
positionTicks, positionTicks,
@@ -290,13 +254,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
subtitleStreamIndex: playback.subtitleStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'stop', eventName: 'stop',
}); });
if (reported === false) {
deps.logWarn?.(
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
);
}
} catch (error) { } catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error); deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
deps.clearActivePlayback();
} }
}; };
} }
@@ -38,7 +38,6 @@ type JellyfinRemoteServiceOptions = {
}; };
onConnected: () => void; onConnected: () => void;
onDisconnected: () => void; onDisconnected: () => void;
logWarn?: (message: string, details?: unknown) => void;
onPlay: (payload: JellyfinRemoteEventPayload) => void; onPlay: (payload: JellyfinRemoteEventPayload) => void;
onPlaystate: (payload: JellyfinRemoteEventPayload) => void; onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void; onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
@@ -111,7 +110,6 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
onDisconnected: () => { onDisconnected: () => {
deps.logWarn('Jellyfin remote websocket disconnected; retrying.'); deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
}, },
logWarn: (message, details) => deps.logWarn(message, details),
onPlay: (payload) => { onPlay: (payload) => {
void deps.handlePlay(payload).catch((error) => { void deps.handlePlay(payload).catch((error) => {
deps.logWarn('Failed handling Jellyfin remote Play event', error); deps.logWarn('Failed handling Jellyfin remote Play event', error);
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
}); });
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
const sequence: CompiledSessionBinding = {
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
};
let nativeKeys: unknown = [];
let failDiscovery = false;
let published: CompiledSessionBinding[] = [];
const events: CompiledSessionBinding[][] = [];
const warnings: string[] = [];
const client = {
connected: true,
send: () => {},
requestProperty: async () => {
if (failDiscovery) throw new Error('temporarily unavailable');
return nativeKeys;
},
};
const runtime = createSessionBindingsRuntime({
configDir: root,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
getMpvClient: () => client,
setSessionBindings: (bindings) => {
published = bindings;
},
setSessionBindingsInitialized: () => {},
logWarn: () => {},
onBindingsChanged: (bindings) => events.push(bindings),
onWarning: (warning) => warnings.push(warning.message),
});
const readArtifact = () =>
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
try {
runtime.persistSessionBindings([sequence]);
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, []);
assert.deepEqual(events.at(-1), readArtifact().bindings);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mpv input binding "g"/);
await runtime.refreshMpvSessionBindings();
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
assert.equal(warnings.length, 1);
failDiscovery = true;
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
failDiscovery = false;
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [sequence]);
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
assert.deepEqual(readArtifact().warnings, []);
client.connected = false;
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [sequence]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+83 -7
View File
@@ -6,16 +6,26 @@ import {
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types'; import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
import { writeSessionBindingsArtifact } from './session-bindings-artifact'; import { writeSessionBindingsArtifact } from './session-bindings-artifact';
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
import {
reserveMpvSequencePrefixes,
resolveSessionSequenceConflicts,
} from '../../shared/session-key-sequences';
import type { SessionBindingWarning } from '../../types/session-bindings';
export interface SessionBindingsRuntimeDeps { export interface SessionBindingsRuntimeDeps {
configDir: string; configDir: string;
getKeybindings: () => Keybinding[]; getKeybindings: () => Keybinding[];
getConfiguredShortcuts: () => ConfiguredShortcuts; getConfiguredShortcuts: () => ConfiguredShortcuts;
getResolvedConfig: () => ResolvedConfig; getResolvedConfig: () => ResolvedConfig;
getMpvClient: () => MpvRuntimeClientLike | null; getMpvClient: () =>
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
| null;
setSessionBindings: (bindings: CompiledSessionBinding[]) => void; setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
setSessionBindingsInitialized: (initialized: boolean) => void; setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string, details?: unknown) => void; logWarn: (message: string, details?: unknown) => void;
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
onWarning?: (warning: SessionBindingWarning) => void;
} }
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): { export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
warnings?: ReturnType<typeof compileSessionBindings>['warnings'], warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
) => void; ) => void;
refreshCurrentSessionBindings: () => void; refreshCurrentSessionBindings: () => void;
refreshMpvSessionBindings: () => Promise<void>;
} { } {
let sourceBindings: CompiledSessionBinding[] = [];
let sourceWarnings: SessionBindingWarning[] = [];
let nativeSnapshot: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
keys: string[];
} | null = null;
let pending: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
promise: Promise<void>;
} | null = null;
let publishedSignature: string | null = null;
let reportedWarnings = new Set<string>();
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' { function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin'; if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32'; if (process.platform === 'win32') return 'win32';
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
bindings: CompiledSessionBinding[], bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [], warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void { ): void {
sourceBindings = bindings;
sourceWarnings = warnings;
publishBindings();
}
function publishBindings(): void {
const client = deps.getMpvClient();
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
const result = resolveSessionSequenceConflicts(
sourceBindings,
reserveMpvSequencePrefixes(keys),
);
const warnings = [...sourceWarnings, ...result.warnings];
const signature = JSON.stringify([
result.bindings,
warnings,
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
]);
if (signature === publishedSignature) return;
const artifact = buildPluginSessionBindingsArtifact({ const artifact = buildPluginSessionBindingsArtifact({
bindings, bindings: result.bindings,
warnings, warnings,
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs, numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
}); });
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.logWarn('[session-bindings] Failed to write session bindings artifact'); deps.logWarn('[session-bindings] Failed to write session bindings artifact');
throw error; throw error;
} }
deps.setSessionBindings(bindings); publishedSignature = signature;
deps.setSessionBindings(result.bindings);
deps.setSessionBindingsInitialized(true); deps.setSessionBindingsInitialized(true);
const nextWarnings = new Set(warnings.map((warning) => warning.message));
for (const warning of warnings) {
if (reportedWarnings.has(warning.message)) continue;
deps.logWarn(`[session-bindings] ${warning.message}`);
deps.onWarning?.(warning);
}
reportedWarnings = nextWarnings;
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) { if (mpvClient?.connected) {
try { try {
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error); deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
} }
} }
deps.onBindingsChanged?.(result.bindings);
}
async function refreshMpvSessionBindings(): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected) {
nativeSnapshot = null;
publishBindings();
return;
}
if (pending?.client === client) return pending.promise;
const promise = (async () => {
try {
const raw = await client.requestProperty('input-bindings');
if (client !== deps.getMpvClient() || !client.connected) return;
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
publishBindings();
} catch {
// Keep the last successful snapshot if discovery is temporarily unavailable.
}
})();
const request = { client, promise };
pending = request;
try {
await promise;
} finally {
if (pending === request) pending = null;
}
} }
function refreshCurrentSessionBindings(): void { function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings(); const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
deps.logWarn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings); persistSessionBindings(compiled.bindings, compiled.warnings);
void refreshMpvSessionBindings();
} }
return { persistSessionBindings, refreshCurrentSessionBindings }; return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
} }
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSubtitleSelectionRuntime } from './subtitle-selection';
function setup() {
let enabled = true;
const properties = new Map<string, unknown>([
['path', '/video.mkv'],
[
'track-list',
[
{ id: 1, type: 'audio' },
{ id: 2, type: 'sub', title: 'Japanese', lang: 'ja', codec: 'ass' },
{ id: 3, type: 'sub', title: 'English', lang: 'en', external: true },
{ id: '4', type: 'sub' },
],
],
['sid', 2],
['secondary-sid', 3],
]);
const commands: unknown[][] = [];
const client = {
connected: true,
requestProperty: async (name: string) => properties.get(name),
request: async (command: unknown[]) => {
commands.push(command);
return { error: 'success' };
},
};
const runtime = createSubtitleSelectionRuntime({
isEnabled: () => enabled,
getMpvClient: () => client,
});
return {
runtime,
properties,
commands,
client,
disable: () => {
enabled = false;
},
};
}
test('subtitle selector lists only valid subtitle tracks and current selections', async () => {
const { runtime, properties } = setup();
assert.deepEqual(await runtime.getState(), {
mediaPath: '/video.mkv',
primary: 2,
secondary: 3,
tracks: [
{ id: 2, label: '#2 · Japanese · ja · ass' },
{ id: 3, label: '#3 · English · en · external' },
],
});
properties.set('sid', 'no');
properties.set('secondary-sid', false);
const state = await runtime.getState();
assert.equal(state.primary, null);
assert.equal(state.secondary, null);
});
test('subtitle selector swaps tracks and supports disabling both tracks', async () => {
const { runtime, commands } = setup();
await runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 });
assert.deepEqual(commands, [
['set_property', 'secondary-sid', 'no'],
['set_property', 'sid', 3],
['set_property', 'secondary-sid', 2],
]);
commands.length = 0;
await runtime.apply({ mediaPath: '/video.mkv', primary: null, secondary: null });
assert.ok(commands.every((command) => command[2] === 'no'));
});
test('subtitle selector rejects stale media, unavailable tracks, duplicate tracks and malformed requests without mutation', async () => {
const { runtime, commands } = setup();
for (const request of [
{ mediaPath: '/other.mkv', primary: 2, secondary: 3 },
{ mediaPath: '/video.mkv', primary: 99, secondary: null },
{ mediaPath: '/video.mkv', primary: 2, secondary: 2 },
{ mediaPath: '/video.mkv', primary: '2', secondary: null },
{ mediaPath: '/video.mkv', primary: -1, secondary: null },
null,
])
await assert.rejects(runtime.apply(request));
assert.deepEqual(commands, []);
});
test('subtitle selector gates access on config and connection and propagates mpv failures', async () => {
const { runtime, client, disable } = setup();
client.request = async () => ({ error: 'property unavailable' });
await assert.rejects(
runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }),
/property unavailable/,
);
client.connected = false;
await assert.rejects(runtime.getState(), /Connect to mpv/);
disable();
await assert.rejects(runtime.getState(), /Enable subtitle selection/);
});
+117
View File
@@ -0,0 +1,117 @@
import type { IpcMain, WebContents } from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseSubtitleSelectionRequest,
type SubtitleSelectionState,
} from '../../shared/subtitle-selection';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
interface SelectionMpvClient {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
request: (command: unknown[]) => Promise<{ error?: string }>;
}
export function openSubtitleSelectionModal(
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
): Promise<boolean> {
return retryOverlayModalOpen(deps, {
modal: 'subtitle-selection',
timeoutMs: 1500,
retryWarning: 'Subtitle selection modal did not acknowledge opening; retrying.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.subtitleSelectionOpen,
modal: 'subtitle-selection',
preferModalWindow: true,
}),
});
}
export function createSubtitleSelectionRuntime(deps: {
isEnabled: () => boolean;
getMpvClient: () => SelectionMpvClient | null;
}) {
function getClient(): SelectionMpvClient {
if (!deps.isEnabled()) throw new Error('Enable subtitle selection in Settings first.');
const client = deps.getMpvClient();
if (!client?.connected) throw new Error('Connect to mpv first.');
return client;
}
async function readState(client: SelectionMpvClient): Promise<SubtitleSelectionState> {
const mediaPath = await client.requestProperty('path');
if (typeof mediaPath !== 'string' || !mediaPath) throw new Error('Open a video first.');
const [rawTracks, primary, secondary] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('secondary-sid'),
]);
const tracks: SubtitleSelectionState['tracks'] = [];
const candidates: unknown[] = Array.isArray(rawTracks) ? rawTracks : [];
for (const track of candidates) {
if (
typeof track !== 'object' ||
track === null ||
!('type' in track) ||
track.type !== 'sub' ||
!('id' in track) ||
typeof track.id !== 'number' ||
!Number.isSafeInteger(track.id) ||
track.id <= 0
)
continue;
const details = [
'title' in track ? track.title : undefined,
'lang' in track ? track.lang : undefined,
'codec' in track ? track.codec : undefined,
].filter((value): value is string => typeof value === 'string' && value.length > 0);
if ('external' in track && track.external === true) details.push('external');
tracks.push({ id: track.id, label: `#${track.id} · ${details.join(' · ') || 'Subtitle'}` });
}
if ((await client.requestProperty('path')) !== mediaPath)
throw new Error('The video changed. Reopen subtitle selection.');
const selected = (value: unknown): number | null =>
tracks.find((track) => track.id === value)?.id ?? null;
return { mediaPath, tracks, primary: selected(primary), secondary: selected(secondary) };
}
async function apply(value: unknown): Promise<void> {
const selection = parseSubtitleSelectionRequest(value);
const client = getClient();
const current = await readState(client);
if (current.mediaPath !== selection.mediaPath)
throw new Error('The video changed. Reopen subtitle selection.');
for (const id of [selection.primary, selection.secondary]) {
if (id !== null && !current.tracks.some((track) => track.id === id))
throw new Error('A selected track is no longer available. Reopen subtitle selection.');
}
const set = async (property: string, id: number | null): Promise<void> => {
const response = await client.request(['set_property', property, id ?? 'no']);
if (response.error && response.error !== 'success') throw new Error(response.error);
};
// Clear secondary first so swapping the two tracks works in mpv.
await set('secondary-sid', null);
await set('sid', selection.primary);
await set('secondary-sid', selection.secondary);
}
return { getState: async () => readState(getClient()), apply };
}
export function registerSubtitleSelectionIpc(deps: {
ipc: Pick<IpcMain, 'handle'>;
isAllowedSender: (sender: WebContents) => boolean;
runtime: ReturnType<typeof createSubtitleSelectionRuntime>;
}): void {
deps.ipc.handle(IPC_CHANNELS.request.getSubtitleSelection, (event) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle selection requires the overlay.');
return deps.runtime.getState();
});
deps.ipc.handle(IPC_CHANNELS.request.applySubtitleSelection, (event, value: unknown) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle selection requires the overlay.');
return deps.runtime.apply(value);
});
}
+14
View File
@@ -81,6 +81,7 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
import type { SubtitleGenerationProgress } from './shared/subtitle-generation'; import type { SubtitleGenerationProgress } from './shared/subtitle-generation';
const overlayLayer = resolveOverlayLayerFromArgv(process.argv); const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
const onSubtitleSelectionOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleSelectionOpen);
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen); const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
type EmptyListener = () => void; type EmptyListener = () => void;
@@ -463,6 +464,11 @@ const electronAPI: ElectronAPI = {
) as Promise<boolean>, ) as Promise<boolean>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> => getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle), ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
onSubtitleSelectionOpen,
getSubtitleSelection: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSelection),
applySubtitleSelection: (
request: import('./shared/subtitle-selection').SubtitleSelectionRequest,
) => ipcRenderer.invoke(IPC_CHANNELS.request.applySubtitleSelection, request),
onSubsyncManualOpen: onSubsyncManualOpenEvent, onSubsyncManualOpen: onSubsyncManualOpenEvent,
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> => runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
@@ -580,6 +586,14 @@ const electronAPI: ElectronAPI = {
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement); ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
}, },
onSessionBindingsChanged: (
callback: (bindings: import('./types').CompiledSessionBinding[]) => void,
) => {
ipcRenderer.on(
IPC_CHANNELS.event.sessionBindingsChanged,
(_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings),
);
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on( ipcRenderer.on(
IPC_CHANNELS.event.configHotReload, IPC_CHANNELS.event.configHotReload,
@@ -4,6 +4,7 @@ type ControllerInteractionModalState = {
jimakuModalOpen: boolean; jimakuModalOpen: boolean;
kikuModalOpen: boolean; kikuModalOpen: boolean;
runtimeOptionsModalOpen: boolean; runtimeOptionsModalOpen: boolean;
subtitleSelectionModalOpen?: boolean;
subsyncModalOpen: boolean; subsyncModalOpen: boolean;
subtitleGenerationModalOpen?: boolean; subtitleGenerationModalOpen?: boolean;
youtubePickerModalOpen: boolean; youtubePickerModalOpen: boolean;
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
state.jimakuModalOpen || state.jimakuModalOpen ||
state.kikuModalOpen || state.kikuModalOpen ||
state.runtimeOptionsModalOpen || state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen || state.subsyncModalOpen ||
Boolean(state.subtitleGenerationModalOpen) || Boolean(state.subtitleGenerationModalOpen) ||
state.youtubePickerModalOpen || state.youtubePickerModalOpen ||
+84
View File
@@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openTsukihime: null, openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null, openSubtitleGeneration: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
@@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async (
testGlobals.restore(); testGlobals.restore();
} }
}); });
test('session binding: g-s opens subtitle selection only after the complete sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openSubtitleSelection', payload: undefined },
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
handlers.updateSessionBindings([]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
{
sourcePath: 'single',
originalKey: 'g',
key: { code: 'KeyG', modifiers: [] },
actionType: 'mpv-command',
command: ['show-text', 'single'],
},
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
} finally {
testGlobals.restore();
}
});
test('an unfinished built-in y chord cannot start a configured sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
+36 -1
View File
@@ -15,6 +15,7 @@ export function createKeyboardHandlers(
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean; handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean; handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean;
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean; handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean;
@@ -133,7 +134,10 @@ export function createKeyboardHandlers(
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
} }
let pendingSequence: { prefix: string; expires: number } | null = null;
function updateSessionBindings(bindings: CompiledSessionBinding[]): void { function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
pendingSequence = null;
ctx.state.sessionBindings = bindings; ctx.state.sessionBindings = bindings;
ctx.state.sessionBindingMap = new Map( ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]), bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
@@ -1049,7 +1053,10 @@ export function createKeyboardHandlers(
window.addEventListener('focus', () => { window.addEventListener('focus', () => {
void importedMpvBindings.refresh(); void importedMpvBindings.refresh();
}); });
window.addEventListener('blur', importedMpvBindings.releaseAll); window.addEventListener('blur', () => {
pendingSequence = null;
importedMpvBindings.releaseAll();
});
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
clearTimeout(lateScriptRefresh); clearTimeout(lateScriptRefresh);
importedMpvBindings.dispose(); importedMpvBindings.dispose();
@@ -1103,6 +1110,13 @@ export function createKeyboardHandlers(
); );
document.addEventListener('keydown', (e: KeyboardEvent) => { document.addEventListener('keydown', (e: KeyboardEvent) => {
const sequence = pendingSequence;
pendingSequence = null;
if (ctx.state.subtitleSelectionModalOpen) {
pendingSequence = null;
options.handleSubtitleSelectionKeydown?.(e);
return;
}
if (ctx.state.subtitleGenerationModalOpen) { if (ctx.state.subtitleGenerationModalOpen) {
options.handleSubtitleGenerationKeydown?.(e); options.handleSubtitleGenerationKeydown?.(e);
return; return;
@@ -1187,6 +1201,7 @@ export function createKeyboardHandlers(
} }
if (isTextEntryTarget(e.target)) { if (isTextEntryTarget(e.target)) {
pendingSequence = null;
return; return;
} }
@@ -1194,6 +1209,16 @@ export function createKeyboardHandlers(
return; return;
} }
const sequenceKey = keyEventToString(e);
if (sequence && !ctx.state.chordPending && Date.now() <= sequence.expires && !e.repeat) {
const binding = ctx.state.sessionBindingMap.get(`${sequence.prefix}-${sequenceKey}`);
if (binding) {
e.preventDefault();
dispatchSessionBinding(binding);
return;
}
}
if (isStatsOverlayToggle(e)) { if (isStatsOverlayToggle(e)) {
e.preventDefault(); e.preventDefault();
window.electronAPI.toggleStatsOverlay(); window.electronAPI.toggleStatsOverlay();
@@ -1276,6 +1301,16 @@ export function createKeyboardHandlers(
dispatchSessionBinding(binding); dispatchSessionBinding(binding);
return; return;
} }
if (
!e.repeat &&
ctx.state.sessionBindings.some((binding) =>
keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`),
)
) {
pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 };
e.preventDefault();
return;
}
if ( if (
keyboardConfigLoaded && keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen && !ctx.state.playlistBrowserModalOpen &&
+37
View File
@@ -833,6 +833,43 @@
</div> </div>
</div> </div>
</div> </div>
<div id="subtitleSelectionModal" class="modal hidden" aria-hidden="true">
<div
class="modal-content subsync-modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="subtitleSelectionTitle"
>
<div class="modal-header">
<h2 id="subtitleSelectionTitle">Select subtitles</h2>
<button id="subtitleSelectionClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="subsync-form">
<label class="subsync-field">
<span>Primary subtitle</span>
<select id="subtitleSelectionPrimary"></select>
</label>
<label class="subsync-field">
<span>Secondary subtitle</span>
<select id="subtitleSelectionSecondary"></select>
</label>
</div>
<div
id="subtitleSelectionStatus"
class="runtime-options-status"
role="status"
aria-live="polite"
></div>
<div class="subsync-footer">
<button id="subtitleSelectionApply" class="kiku-confirm-button" type="button">
Apply
</button>
</div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true"> <div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content"> <div class="modal-content subsync-modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -225,6 +225,8 @@ function describeSessionAction(
return 'Open jimaku'; return 'Open jimaku';
case 'openTsukihime': case 'openTsukihime':
return 'Open TsukiHime'; return 'Open TsukiHime';
case 'openSubtitleSelection':
return 'Select subtitle tracks';
case 'openSubtitleGeneration': case 'openSubtitleGeneration':
return 'Generate Japanese subtitles'; return 'Generate Japanese subtitles';
case 'openYoutubePicker': case 'openYoutubePicker':
@@ -268,6 +270,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'openJimaku': case 'openJimaku':
case 'openTsukihime': case 'openTsukihime':
case 'openCharacterDictionaryManager': case 'openCharacterDictionaryManager':
case 'openSubtitleSelection':
case 'openSubtitleGeneration': case 'openSubtitleGeneration':
case 'openControllerSelect': case 'openControllerSelect':
case 'openControllerDebug': case 'openControllerDebug':
+172
View File
@@ -0,0 +1,172 @@
import type { SubtitleSelectionState } from '../../shared/subtitle-selection';
import type { RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
import { createModalFocusGuard } from './modal-focus-guard';
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
const node = document.getElementById(id);
if (!(node instanceof constructor)) throw new Error(`Missing subtitle selection element: ${id}`);
return node;
}
export function createSubtitleSelectionModal(
ctx: RendererContext,
options: { syncSettingsModalSubtitleSuppression: () => void },
) {
const dom = {
modal: element('subtitleSelectionModal', HTMLDivElement),
primary: element('subtitleSelectionPrimary', HTMLSelectElement),
secondary: element('subtitleSelectionSecondary', HTMLSelectElement),
status: element('subtitleSelectionStatus', HTMLDivElement),
apply: element('subtitleSelectionApply', HTMLButtonElement),
close: element('subtitleSelectionClose', HTMLButtonElement),
};
let snapshot: SubtitleSelectionState | null = null;
let generation = 0;
let pending = false;
let priorFocus: Element | null = null;
const focus = createModalFocusGuard({
isOpen: () => ctx.state.subtitleSelectionModalOpen,
getModalRoot: () => dom.modal,
getPreferredFocusTargets: () => [dom.primary, dom.secondary, dom.apply],
getFallbackFocusTarget: () => dom.close,
isModalLayer: ctx.platform.isModalLayer,
});
function status(message: string, error = false): void {
dom.status.textContent = message;
dom.status.classList.toggle('error', error);
}
function updateControls(): void {
const disabled = pending || !snapshot;
dom.primary.disabled = disabled;
dom.secondary.disabled = disabled;
const duplicate = dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value;
dom.apply.disabled = disabled || duplicate;
for (const option of dom.secondary.options)
option.disabled = option.value !== 'no' && option.value === dom.primary.value;
}
function populate(select: HTMLSelectElement, selected: number | null): void {
select.replaceChildren();
for (const track of [{ id: null, label: 'None' }, ...(snapshot?.tracks ?? [])]) {
const option = document.createElement('option');
option.value = track.id === null ? 'no' : String(track.id);
option.textContent = track.label;
select.append(option);
}
select.value = selected === null ? 'no' : String(selected);
}
async function refresh(openGeneration: number): Promise<void> {
try {
const next = await window.electronAPI.getSubtitleSelection();
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
snapshot = next;
populate(dom.primary, next.primary);
populate(dom.secondary, next.secondary);
status(
next.tracks.length
? 'Choose subtitle tracks, then apply.'
: 'No subtitle tracks loaded in this video.',
);
} catch (cause) {
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
status(cause instanceof Error ? cause.message : 'Could not read subtitle tracks.', true);
} finally {
if (generation === openGeneration && ctx.state.subtitleSelectionModalOpen) {
pending = false;
updateControls();
focus.focusFallbackTarget();
}
}
}
function open(): void {
if (ctx.state.subtitleSelectionModalOpen) return;
priorFocus = document.activeElement;
snapshot = null;
pending = true;
generation += 1;
populate(dom.primary, null);
populate(dom.secondary, null);
status('Loading subtitle tracks...');
updateControls();
ctx.state.subtitleSelectionModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
dom.modal.classList.remove('hidden');
dom.modal.setAttribute('aria-hidden', 'false');
syncOverlayMouseIgnoreState(ctx);
focus.attach();
focus.focusFallbackTarget();
window.electronAPI.notifyOverlayModalOpened('subtitle-selection');
void refresh(generation);
}
function close(): void {
if (!ctx.state.subtitleSelectionModalOpen) return;
generation += 1;
ctx.state.subtitleSelectionModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
dom.modal.classList.add('hidden');
dom.modal.setAttribute('aria-hidden', 'true');
focus.detach();
window.electronAPI.notifyOverlayModalClosed('subtitle-selection');
syncOverlayMouseIgnoreState(ctx);
if (priorFocus instanceof HTMLElement) priorFocus.focus({ preventScroll: true });
priorFocus = null;
}
async function apply(): Promise<void> {
if (dom.apply.disabled || pending || !snapshot) return;
const openGeneration = generation;
pending = true;
updateControls();
status('Applying subtitle tracks...');
try {
await window.electronAPI.applySubtitleSelection({
mediaPath: snapshot.mediaPath,
primary: dom.primary.value === 'no' ? null : Number(dom.primary.value),
secondary: dom.secondary.value === 'no' ? null : Number(dom.secondary.value),
});
if (generation === openGeneration) close();
} catch (cause) {
if (generation === openGeneration)
status(cause instanceof Error ? cause.message : 'Could not select subtitle tracks.', true);
} finally {
if (generation === openGeneration) {
pending = false;
updateControls();
}
}
}
function handleKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
close();
} else if (
event.key === 'Enter' &&
!(event.target instanceof HTMLSelectElement) &&
event.target !== dom.close
) {
event.preventDefault();
void apply();
}
return true;
}
function wireDomEvents(): void {
dom.close.addEventListener('click', close);
dom.apply.addEventListener('click', () => void apply());
dom.primary.addEventListener('change', () => {
if (dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value)
dom.secondary.value = 'no';
updateControls();
});
dom.secondary.addEventListener('change', updateControls);
}
return { open, close, handleKeydown, wireDomEvents, dispose: () => focus.detach() };
}
+1
View File
@@ -10,6 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
state.youtubePickerModalOpen || state.youtubePickerModalOpen ||
state.kikuModalOpen || state.kikuModalOpen ||
state.runtimeOptionsModalOpen || state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen || state.subsyncModalOpen ||
state.subtitleGenerationModalOpen || state.subtitleGenerationModalOpen ||
state.sessionHelpModalOpen, state.sessionHelpModalOpen,
+21 -1
View File
@@ -44,6 +44,7 @@ import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selectio
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js'; import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js'; import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js'; import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
import { createSubsyncModal } from './modals/subsync.js'; import { createSubsyncModal } from './modals/subsync.js';
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js'; import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js'; import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
@@ -153,6 +154,12 @@ const modalDescriptors = [
close: () => characterDictionaryModal.closeCharacterDictionaryModal(), close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
suppressesSubtitles: true, suppressesSubtitles: true,
}, },
{
id: 'subtitle-selection',
isOpen: () => ctx.state.subtitleSelectionModalOpen,
close: () => subtitleSelectionModal.close(),
suppressesSubtitles: true,
},
{ {
id: 'subsync', id: 'subsync',
isOpen: () => ctx.state.subsyncModalOpen, isOpen: () => ctx.state.subsyncModalOpen,
@@ -216,6 +223,9 @@ const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
syncSettingsModalSubtitleSuppression,
});
const subsyncModal = createSubsyncModal(ctx, { const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
@@ -293,6 +303,7 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
const keyboardHandlers = createKeyboardHandlers(ctx, { const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown, handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown, handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown,
@@ -624,6 +635,9 @@ function registerModalOpenHandlers(): void {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
}); });
}); });
window.electronAPI.onSubtitleSelectionOpen(() => {
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => { runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload); subsyncModal.openSubsyncModal(payload);
@@ -849,6 +863,7 @@ async function init(): Promise<void> {
playlistBrowserModal.wireDomEvents(); playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents();
subtitleSelectionModal.wireDomEvents();
subsyncModal.wireDomEvents(); subsyncModal.wireDomEvents();
subtitleGenerationModal.wireDomEvents(); subtitleGenerationModal.wireDomEvents();
controllerSelectModal.wireDomEvents(); controllerSelectModal.wireDomEvents();
@@ -858,6 +873,7 @@ async function init(): Promise<void> {
subtitleSidebarModal.wireDomEvents(); subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents(); characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
subtitleSelectionModal.dispose();
subtitleGenerationModal.dispose(); subtitleGenerationModal.dispose();
subtitleSidebarModal.disposeDomEvents(); subtitleSidebarModal.disposeDomEvents();
}); });
@@ -867,9 +883,13 @@ async function init(): Promise<void> {
runtimeOptionsModal.updateRuntimeOptions(options); runtimeOptionsModal.updateRuntimeOptions(options);
}); });
}); });
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
runGuarded('config:hot-reload', () => { runGuarded('config:hot-reload', () => {
keyboardHandlers.updateSessionBindings(payload.sessionBindings); void window.electronAPI
.getSessionBindings()
.then(keyboardHandlers.updateSessionBindings)
.catch((error: unknown) => console.error('Could not refresh session bindings', error));
void keyboardHandlers.refreshConfiguredShortcuts(); void keyboardHandlers.refreshConfiguredShortcuts();
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode); subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
+2
View File
@@ -89,6 +89,7 @@ export type RendererState = {
characterDictionaryStatus: string; characterDictionaryStatus: string;
subsyncModalOpen: boolean; subsyncModalOpen: boolean;
subtitleSelectionModalOpen: boolean;
subtitleGenerationModalOpen: boolean; subtitleGenerationModalOpen: boolean;
subsyncSubtitleTracks: SubsyncSubtitleTrack[]; subsyncSubtitleTracks: SubsyncSubtitleTrack[];
subsyncSubmitting: boolean; subsyncSubmitting: boolean;
@@ -223,6 +224,7 @@ export function createRendererState(): RendererState {
characterDictionaryStatus: '', characterDictionaryStatus: '',
subsyncModalOpen: false, subsyncModalOpen: false,
subtitleSelectionModalOpen: false,
subtitleGenerationModalOpen: false, subtitleGenerationModalOpen: false,
subsyncSubtitleTracks: [], subsyncSubtitleTracks: [],
subsyncSubmitting: false, subsyncSubmitting: false,
+10
View File
@@ -3228,6 +3228,16 @@ iframe[id^='yomitan-popup'],
padding: 8px 10px; padding: 8px 10px;
} }
.subsync-field select:focus-visible {
outline: 2px solid var(--ctp-blue);
outline-offset: 2px;
}
#subtitleSelectionModal .kiku-confirm-button:disabled {
opacity: 0.5;
cursor: default;
}
.subsync-footer { .subsync-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+5
View File
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
export const OVERLAY_HOSTED_MODALS = [ export const OVERLAY_HOSTED_MODALS = [
'runtime-options', 'runtime-options',
'subsync', 'subsync',
'subtitle-selection',
'subtitle-generation', 'subtitle-generation',
'jimaku', 'jimaku',
'tsukihime', 'tsukihime',
@@ -51,6 +52,8 @@ export const IPC_CHANNELS = {
dispatchSessionAction: 'session-action:dispatch', dispatchSessionAction: 'session-action:dispatch',
}, },
request: { request: {
getSubtitleSelection: 'subtitle-selection:get',
applySubtitleSelection: 'subtitle-selection:apply',
requestSubtitleGenerationOpen: 'subtitle-generation:open', requestSubtitleGenerationOpen: 'subtitle-generation:open',
getSubtitleGenerationStatus: 'subtitle-generation:status', getSubtitleGenerationStatus: 'subtitle-generation:status',
startSubtitleGeneration: 'subtitle-generation:start', startSubtitleGeneration: 'subtitle-generation:start',
@@ -143,6 +146,7 @@ export const IPC_CHANNELS = {
mediaTimingReviewResolve: 'media-timing-review:resolve', mediaTimingReviewResolve: 'media-timing-review:resolve',
}, },
event: { event: {
subtitleSelectionOpen: 'subtitle-selection:opened',
subtitleGenerationOpen: 'subtitle-generation:opened', subtitleGenerationOpen: 'subtitle-generation:opened',
subtitleGenerationProgress: 'subtitle-generation:progress', subtitleGenerationProgress: 'subtitle-generation:progress',
subtitleSet: 'subtitle:set', subtitleSet: 'subtitle:set',
@@ -174,6 +178,7 @@ export const IPC_CHANNELS = {
controllerDebugOpen: 'controller-debug:open', controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle', subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
sessionBindingsChanged: 'session-bindings:changed',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification', overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle', notificationHistoryToggle: 'notification-history:toggle',
+1
View File
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerDebug', 'openControllerDebug',
'openJimaku', 'openJimaku',
'openTsukihime', 'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration', 'openSubtitleGeneration',
'openYoutubePicker', 'openYoutubePicker',
'openPlaylistBrowser', 'openPlaylistBrowser',
+10
View File
@@ -103,3 +103,13 @@ test('SubMiner ownership recognizes leading mpv prefixes without matching comman
['d', 'e', 'f', 'g'], ['d', 'e', 'f', 'g'],
); );
}); });
test('sequence conflict discovery allows winning ignore bindings used by mpv sequence prefixes', () => {
const bindings = [
{ key: 'g', cmd: 'show-text old', priority: 0 },
{ key: 'g', cmd: 'no-osd ignore', priority: 1 },
{ key: 'h', cmd: 'show-text action', priority: 0 },
];
assert.deepEqual(parseMpvInputBindingKeys(bindings), ['g', 'h']);
assert.deepEqual(parseMpvInputBindingKeys(bindings, { includeIgnored: false }), ['h']);
});
+13 -4
View File
@@ -59,9 +59,12 @@ export function keyboardEventToMpvKey(
return normalizeMpvInputKey([...modifiers, key].join('+')); return normalizeMpvInputKey([...modifiers, key].join('+'));
} }
export function parseMpvInputBindingKeys(value: unknown): string[] { export function parseMpvInputBindingKeys(
value: unknown,
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
const bindings = new Map<string, { priority: number; owned: boolean }>(); const bindings = new Map<string, { priority: number; owned: boolean; ignored: boolean }>();
for (const candidate of value) { for (const candidate of value) {
const entry: unknown = candidate; const entry: unknown = candidate;
if ( if (
@@ -94,8 +97,14 @@ export function parseMpvInputBindingKeys(value: unknown): string[] {
entry.priority > previous.priority || entry.priority > previous.priority ||
(entry.priority === previous.priority && owned) (entry.priority === previous.priority && owned)
) { ) {
bindings.set(key, { priority: entry.priority, owned }); bindings.set(key, {
priority: entry.priority,
owned,
ignored: entry.cmd.trim().replace(MPV_COMMAND_PREFIXES, '') === 'ignore',
});
} }
} }
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key); return [...bindings]
.filter(([, binding]) => !binding.owned && (includeIgnored || !binding.ignored))
.map(([key]) => key);
} }
+71
View File
@@ -0,0 +1,71 @@
import type {
CompiledSessionBinding,
SessionBindingWarning,
SessionKeySpec,
} from '../types/session-bindings';
export interface SessionKeyReservation {
key: SessionKeySpec;
path: string;
}
export function getSessionSequencePrefix(key: SessionKeySpec): SessionKeySpec | null {
const match = /^(Key[A-Z])-Key[A-Z]$/.exec(key.code);
return match?.[1] ? { code: match[1], modifiers: key.modifiers } : null;
}
function signature(key: SessionKeySpec): string {
return [...key.modifiers, key.code].join('+');
}
export function resolveSessionSequenceConflicts(
bindings: CompiledSessionBinding[],
reservations: SessionKeyReservation[] = [],
): { bindings: CompiledSessionBinding[]; warnings: SessionBindingWarning[] } {
const singles = new Map<string, string[]>();
for (const { key, path } of [
...bindings.map((binding) => ({ key: binding.key, path: binding.sourcePath })),
...reservations,
]) {
if (getSessionSequencePrefix(key)) continue;
const id = signature(key);
singles.set(id, [...(singles.get(id) ?? []), path]);
}
const warnings: SessionBindingWarning[] = [];
const effective = bindings.filter((binding) => {
const prefix = getSessionSequencePrefix(binding.key);
if (!prefix) return true;
const conflicts = singles.get(signature(prefix));
if (!conflicts?.length) return true;
const paths = [...new Set(conflicts)];
warnings.push({
kind: 'conflict',
path: binding.sourcePath,
value: binding.originalKey,
conflictingPaths: paths,
message: `Disabled sequence "${binding.originalKey}" (${binding.sourcePath}): its first key is reserved by ${paths.join(', ')}. Single-key bindings take priority; remap the sequence or its conflicting binding.`,
});
return false;
});
return { bindings: effective, warnings };
}
// Imported mpv keys preserve case: g and G are different strokes.
export function reserveMpvSequencePrefixes(keys: string[]): SessionKeyReservation[] {
return keys.flatMap((value) => {
const parts = value.split('+');
const letter = parts.pop();
if (!letter || !/^[a-z]$/i.test(letter)) return [];
const modifiers: SessionKeySpec['modifiers'] = [];
if (parts.includes('ctrl')) modifiers.push('ctrl');
if (parts.includes('alt')) modifiers.push('alt');
if (parts.includes('shift') || /^[A-Z]$/.test(letter)) modifiers.push('shift');
if (parts.includes('meta')) modifiers.push('meta');
return [
{
key: { code: `Key${letter.toUpperCase()}`, modifiers },
path: `mpv input binding "${value}"`,
},
];
});
}
+33
View File
@@ -0,0 +1,33 @@
export interface SubtitleSelectionState {
mediaPath: string;
tracks: { id: number; label: string }[];
primary: number | null;
secondary: number | null;
}
export type SubtitleSelectionRequest = Pick<
SubtitleSelectionState,
'mediaPath' | 'primary' | 'secondary'
>;
export function parseSubtitleSelectionRequest(value: unknown): SubtitleSelectionRequest {
if (
typeof value !== 'object' ||
value === null ||
!('mediaPath' in value) ||
typeof value.mediaPath !== 'string' ||
!value.mediaPath ||
!('primary' in value) ||
!isTrackSelection(value.primary) ||
!('secondary' in value) ||
!isTrackSelection(value.secondary)
)
throw new Error('Invalid subtitle selection.');
if (value.primary !== null && value.primary === value.secondary)
throw new Error('Choose different primary and secondary tracks.');
return { mediaPath: value.mediaPath, primary: value.primary, secondary: value.secondary };
}
function isTrackSelection(value: unknown): value is number | null {
return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
}
+3
View File
@@ -126,6 +126,7 @@ export interface ShortcutsConfig {
openRuntimeOptions?: string | null; openRuntimeOptions?: string | null;
openJimaku?: string | null; openJimaku?: string | null;
openTsukihime?: string | null; openTsukihime?: string | null;
openSubtitleSelection?: string | null;
openSubtitleGeneration?: string | null; openSubtitleGeneration?: string | null;
openSessionHelp?: string | null; openSessionHelp?: string | null;
openControllerSelect?: string | null; openControllerSelect?: string | null;
@@ -152,6 +153,7 @@ export interface Config {
shortcuts?: RawShortcutsConfig; shortcuts?: RawShortcutsConfig;
secondarySub?: SecondarySubConfig; secondarySub?: SecondarySubConfig;
subsync?: SubsyncConfig; subsync?: SubsyncConfig;
subtitleSelection?: { enabled?: boolean };
subtitleGeneration?: Partial<SubtitleGenerationConfig>; subtitleGeneration?: Partial<SubtitleGenerationConfig>;
startupWarmups?: StartupWarmupsConfig; startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig; subtitleStyle?: SubtitleStyleConfig;
@@ -304,6 +306,7 @@ export interface ResolvedConfig {
shortcuts: Required<ShortcutsConfig>; shortcuts: Required<ShortcutsConfig>;
secondarySub: Required<SecondarySubConfig>; secondarySub: Required<SecondarySubConfig>;
subsync: Required<SubsyncConfig>; subsync: Required<SubsyncConfig>;
subtitleSelection: { enabled: boolean };
subtitleGeneration: SubtitleGenerationConfig; subtitleGeneration: SubtitleGenerationConfig;
startupWarmups: { startupWarmups: {
lowPowerMode: boolean; lowPowerMode: boolean;
+10
View File
@@ -525,6 +525,13 @@ export interface ElectronAPI {
focusMainWindow: () => Promise<void>; focusMainWindow: () => Promise<void>;
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>; activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>; getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
onSubtitleSelectionOpen: (callback: () => void) => void;
getSubtitleSelection: () => Promise<
import('../shared/subtitle-selection').SubtitleSelectionState
>;
applySubtitleSelection: (
request: import('../shared/subtitle-selection').SubtitleSelectionRequest,
) => Promise<void>;
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void; onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void; onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
@@ -604,6 +611,7 @@ export interface ElectronAPI {
modal: modal:
| 'runtime-options' | 'runtime-options'
| 'subsync' | 'subsync'
| 'subtitle-selection'
| 'subtitle-generation' | 'subtitle-generation'
| 'jimaku' | 'jimaku'
| 'tsukihime' | 'tsukihime'
@@ -622,6 +630,7 @@ export interface ElectronAPI {
modal: modal:
| 'runtime-options' | 'runtime-options'
| 'subsync' | 'subsync'
| 'subtitle-selection'
| 'subtitle-generation' | 'subtitle-generation'
| 'jimaku' | 'jimaku'
| 'tsukihime' | 'tsukihime'
@@ -637,6 +646,7 @@ export interface ElectronAPI {
| 'changelog', | 'changelog',
) => void; ) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
} }
+1
View File
@@ -23,6 +23,7 @@ export type SessionActionId =
| 'openControllerDebug' | 'openControllerDebug'
| 'openJimaku' | 'openJimaku'
| 'openTsukihime' | 'openTsukihime'
| 'openSubtitleSelection'
| 'openSubtitleGeneration' | 'openSubtitleGeneration'
| 'openYoutubePicker' | 'openYoutubePicker'
| 'openPlaylistBrowser' | 'openPlaylistBrowser'