mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 17:16:19 -07:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbc36dc41
|
||
|
|
e93b696e71
|
||
|
|
7c161723cd | ||
|
|
b0fb39a8f5 | ||
|
|
a7a302bdd0 | ||
|
|
6a1d1211ae
|
||
|
|
7585d430ae
|
||
|
|
122ff9a3c5
|
||
|
|
786bd5af81
|
||
|
|
42d1384c68 |
@@ -29,6 +29,9 @@ jobs:
|
|||||||
- name: Verify Windows launcher bootstrap
|
- name: Verify Windows launcher bootstrap
|
||||||
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
|
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
|
||||||
|
|
||||||
|
- name: Verify native mpv process launch
|
||||||
|
run: bun test src/main/runtime/mpv-process.test.ts
|
||||||
|
|
||||||
- name: Verify POSIX launcher bootstrap
|
- name: Verify POSIX launcher bootstrap
|
||||||
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
|
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
|
||||||
|
|
||||||
|
|||||||
+121
@@ -1,5 +1,126 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.20.0 (2026-09-23)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Japanese Subtitle Generation**:
|
||||||
|
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||||
|
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||||
|
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||||
|
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||||
|
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||||
|
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||||
|
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||||
|
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||||
|
- **Subtitle Selection Modal**:
|
||||||
|
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||||
|
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||||
|
- Single-key actions take priority over configured key sequence prefixes.
|
||||||
|
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||||
|
- **Subtitle Sidebar Copy**:
|
||||||
|
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||||
|
- Selecting text does not seek playback and does not require mining a card.
|
||||||
|
- **Media Timing Review Screenshot Picker**:
|
||||||
|
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||||
|
- Step through decoded frames one at a time to get the exact frame you want.
|
||||||
|
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||||
|
- **mpv Keybindings in the Overlay**:
|
||||||
|
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||||
|
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||||
|
- These bindings apply only to the current session and are not listed in the help menu.
|
||||||
|
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||||
|
- **TMDB Live-Action Library**:
|
||||||
|
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||||
|
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||||
|
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||||
|
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||||
|
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||||
|
- **YouTube Library Kind**:
|
||||||
|
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||||
|
- New All Titles, Anime, and YouTube Library filters.
|
||||||
|
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||||
|
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Launcher Uses Bundled Bun**:
|
||||||
|
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||||
|
- Recognized legacy launchers migrate automatically.
|
||||||
|
- Windows gets a `subminer.cmd` launcher download.
|
||||||
|
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||||
|
- **Faster Sync Transfers**:
|
||||||
|
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||||
|
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||||
|
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||||
|
- Older peers still work without the upload cache.
|
||||||
|
- Transfers abort after 30 minutes.
|
||||||
|
- **Stats Server Request Safety**:
|
||||||
|
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||||
|
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||||
|
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||||
|
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||||
|
- **Smaller Downloads**:
|
||||||
|
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||||
|
- All windows now share one Japanese UI font.
|
||||||
|
- Release builds publish package size reports that compare against the previous release.
|
||||||
|
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Jellyfin 12 Compatibility**:
|
||||||
|
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||||
|
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, 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.
|
||||||
|
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||||
|
- **Jellyfin Privacy and Playback**:
|
||||||
|
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||||
|
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||||
|
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||||
|
- **Anki Mining**:
|
||||||
|
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||||
|
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||||
|
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||||
|
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||||
|
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||||
|
- **Stats Server Stability**:
|
||||||
|
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||||
|
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||||
|
- Shutdown waits only a limited time for active requests to finish.
|
||||||
|
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||||
|
- **Subtitle Sidebar**:
|
||||||
|
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||||
|
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||||
|
- **Settings Save Feedback**:
|
||||||
|
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||||
|
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||||
|
- **Overlay Windows**:
|
||||||
|
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||||
|
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||||
|
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||||
|
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||||
|
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||||
|
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||||
|
- **Jellyfin**:
|
||||||
|
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||||
|
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||||
|
- **Stats Library**:
|
||||||
|
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||||
|
- Documented YouTube channel filtering and video statistics in the Library.
|
||||||
|
- **Mining**:
|
||||||
|
- Documented choosing the screenshot separately in media timing review.
|
||||||
|
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||||
|
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Removed duplicate source and launcher smoke runs from the reusable CI quality gate. Every distinct test lane and failure artifact is kept.
|
||||||
|
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check. It covers compiled stats startup, the HTTP service, native SQLite, port conflicts, and cleanup.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.19.6 (2026-09-04)
|
## v0.19.6 (2026-09-04)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.15.3",
|
"electron-builder": "26.16.1",
|
||||||
"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.15.3",
|
"app-builder-lib": "26.16.1",
|
||||||
"brace-expansion": "5.0.9",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.16.1",
|
||||||
"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@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||||
|
|
||||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
|
"@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.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="],
|
"app-builder-lib": ["app-builder-lib@26.16.1", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.16.1", "electron-builder-squirrel-windows": "26.16.1" } }, "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg=="],
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"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.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="],
|
"builder-util": ["builder-util@26.16.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg=="],
|
||||||
|
|
||||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
"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.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="],
|
"dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"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.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
|
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||||
|
|
||||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="],
|
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A=="],
|
||||||
|
|
||||||
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
|
"electron-publish": ["electron-publish@26.16.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q=="],
|
||||||
|
|
||||||
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Documented private-runtime launcher installation, simplified first-run setup, custom app locations, legacy launcher migration, and package-managed updates. Updated release-note download guidance for the bundled runtime and Windows launcher.
|
|
||||||
- Documented the bundled Bun runtime's MIT and LGPL licensing, where the notices live in the app, and the corresponding-source release asset. The AUR package now lists the bundled runtime licenses and installs their notices under `/usr/share/licenses/subminer-bin`, and `subminer-assets.tar.gz` includes the same notices.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Made every installed and downloadable launcher use the Bun runtime included with SubMiner. Added a Windows `subminer.cmd` download, persistent private runtime caches for Linux and Windows, and migration for recognized legacy launchers. Deferred migrations survive overlapping update checks and remain pending when startup cannot safely refresh the launcher.
|
|
||||||
- Simplified first-run setup to a single optional launcher control, with runtime repair guidance shown only when needed.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: ci
|
|
||||||
|
|
||||||
- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: verification
|
|
||||||
|
|
||||||
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: sync
|
|
||||||
|
|
||||||
- Sync uses compressed, incremental rsync transfers on compatible macOS and Linux machines, caching the last received snapshot per peer to reduce traffic on subsequent syncs. Cache helpers work through the launcher; older apps and launchers fall back to compressed transfers without an upload cache.
|
|
||||||
- Machines without compatible rsync, including Windows endpoints, automatically use compressed scp transfers.
|
|
||||||
- Rsync explicitly uses SSH and aborts transfers that exceed 30 minutes before merging.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: startup
|
|
||||||
|
|
||||||
- Fixed first launch exiting on macOS when the SubMiner config directory did not yet exist by creating it before acquiring the startup lock.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats.
|
|
||||||
- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived.
|
|
||||||
- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments.
|
|
||||||
- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Clicking a subtitle sidebar cue releases row focus, and Space no longer seeks back to a focused cue. Enter still seeks the focused cue, and Space keeps its configured playback action.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Stats server startup reports port conflicts without crashing SubMiner, shares concurrent startup requests, and shows in-app startup errors through configured status notifications.
|
|
||||||
- Background stop cancels pending background startup without disconnecting foreground-only dashboards. Shutdown bounds the wait for active HTTP requests and awaits tracker finalization before exit, with a deadline for forced application exit.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Keep Hyprland recovery dialogs above SubMiner windows so overlay placement updates do not cover their Wait and Close buttons.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process.
|
|
||||||
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
|
|
||||||
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
|
|
||||||
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
|
|
||||||
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections. Use a wider generation modal to reduce text wrapping and scrolling, while keeping it within smaller player windows.
|
|
||||||
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata.
|
|
||||||
- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: jimaku
|
|
||||||
|
|
||||||
- Jimaku modal gains Anime / Live action tabs so searches can pull from Jimaku's live action catalogue instead of only anime entries (Arrow Left / Right switch tabs).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: mining
|
|
||||||
|
|
||||||
- Document independent screenshot selection in media timing review.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: mining
|
|
||||||
|
|
||||||
- Choose a still screenshot independently of the audio in media timing review, with a live preview, a separate time slider, and decoded-frame stepping. Local video and seekable remote streams such as Jellyfin are supported.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- The overlay discovers non-conflicting keyboard bindings from mpv defaults, input.conf, and loaded scripts in the background. SubMiner controls and explicitly disabled bindings take precedence. Discovered bindings stay session-only and do not appear in SubMiner's help menu.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Reduced installer and unpacked app size by excluding documentation demo media, dependency source maps, TypeScript files, test and fixture directories, other development files, and unused Koffi platform binaries, and sharing the existing Japanese UI font across windows.
|
|
||||||
- Added package content checks, published size reports with release comparisons, and packaged asset/native-module smoke checks to the shared stable and prerelease build workflow. Size growth is reported without blocking releases.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- Clarified live-setting save feedback, mixed restart warnings, and subtitle-generation reload behavior.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- Settings marked LIVE now use the same reload policy as save results, fixing false restart warnings for notifications and subtitle generation.
|
|
||||||
- Mixed saves apply live changes and list only sections with changed fields that require a restart.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: changed
|
|
||||||
breaking: true
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies.
|
|
||||||
- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Keep the subtitle sidebar near playback during gaps when the subtitle file has a cue starting at zero.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: sync
|
|
||||||
|
|
||||||
- Documented compressed transfers, incremental sync cache storage, and compatibility with older peers.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
|
|
||||||
- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
|
|
||||||
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library kind selector gained a Live Action option alongside Anime and YouTube.
|
|
||||||
- Provider reassignment preserves the previous link and artwork if the replacement download fails, and refreshes completion totals when the episode count changes. Merges and sync keep conflicting AniList and TMDB identities separate, and the merge dialog explains when a selection mixes the two instead of failing.
|
|
||||||
- TMDB credential commands cache successful output and wait 30 seconds before retrying failed or empty output, using the bundled key in the meantime when available.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: yomitan
|
|
||||||
|
|
||||||
- Updated bundled Yomitan with upstream 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Document YouTube channel filtering and video statistics in the Library.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Store YouTube channels as a separate media kind and migrate existing channel entries without changing viewing history or manual video assignments, including channels created after temporarily returning to an older build.
|
|
||||||
- Add All Titles, Anime, and YouTube Library filters, identify channel pages, and keep channels out of AniList matching, season repair, and duplicate recommendations.
|
|
||||||
- Keep same-title anime and YouTube records separate in storage and sync while repairing legacy channel classification.
|
|
||||||
- Refuse merges and video moves that would mix an anime entry with a YouTube channel; the move picker and merge selection only combine entries of the same kind.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
|
|||||||
|
|
||||||
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
|
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
|
||||||
|
|
||||||
|
When word-card enrichment changes the sentence context, including an expanded timing-review selection, SubMiner regenerates `SentenceFurigana` from the final sentence. Unchanged sentences keep their existing furigana formatting. If generation fails, SubMiner clears stale furigana so compatible templates can fall back to `Sentence`.
|
||||||
|
|
||||||
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
|
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
|
||||||
|
|
||||||
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
|
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
|
||||||
|
|||||||
+134
-8
@@ -1,6 +1,132 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.19.6 (2026-09-04)
|
## v0.20.0 (2026-09-23)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- **Japanese Subtitle Generation**:
|
||||||
|
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||||
|
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||||
|
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||||
|
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||||
|
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||||
|
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||||
|
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||||
|
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||||
|
- **Subtitle Selection Modal**:
|
||||||
|
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||||
|
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||||
|
- Single-key actions take priority over configured key sequence prefixes.
|
||||||
|
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||||
|
- **Subtitle Sidebar Copy**:
|
||||||
|
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||||
|
- Selecting text does not seek playback and does not require mining a card.
|
||||||
|
- **Media Timing Review Screenshot Picker**:
|
||||||
|
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||||
|
- Step through decoded frames one at a time to get the exact frame you want.
|
||||||
|
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||||
|
- **mpv Keybindings in the Overlay**:
|
||||||
|
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||||
|
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||||
|
- These bindings apply only to the current session and are not listed in the help menu.
|
||||||
|
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||||
|
- **TMDB Live-Action Library**:
|
||||||
|
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||||
|
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||||
|
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||||
|
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||||
|
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||||
|
- **YouTube Library Kind**:
|
||||||
|
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||||
|
- New All Titles, Anime, and YouTube Library filters.
|
||||||
|
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||||
|
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **Launcher Uses Bundled Bun**:
|
||||||
|
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||||
|
- Recognized legacy launchers migrate automatically.
|
||||||
|
- Windows gets a `subminer.cmd` launcher download.
|
||||||
|
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||||
|
- **Faster Sync Transfers**:
|
||||||
|
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||||
|
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||||
|
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||||
|
- Older peers still work without the upload cache.
|
||||||
|
- Transfers abort after 30 minutes.
|
||||||
|
- **Stats Server Request Safety**:
|
||||||
|
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||||
|
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||||
|
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||||
|
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||||
|
- **Smaller Downloads**:
|
||||||
|
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||||
|
- All windows now share one Japanese UI font.
|
||||||
|
- Release builds publish package size reports that compare against the previous release.
|
||||||
|
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Jellyfin 12 Compatibility**:
|
||||||
|
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||||
|
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, 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.
|
||||||
|
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||||
|
- **Jellyfin Privacy and Playback**:
|
||||||
|
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||||
|
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||||
|
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||||
|
- **Anki Mining**:
|
||||||
|
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||||
|
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||||
|
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||||
|
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||||
|
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||||
|
- **Stats Server Stability**:
|
||||||
|
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||||
|
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||||
|
- Shutdown waits only a limited time for active requests to finish.
|
||||||
|
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||||
|
- **Subtitle Sidebar**:
|
||||||
|
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||||
|
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||||
|
- **Settings Save Feedback**:
|
||||||
|
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||||
|
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||||
|
- **Overlay Windows**:
|
||||||
|
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||||
|
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||||
|
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||||
|
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||||
|
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||||
|
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||||
|
- **Jellyfin**:
|
||||||
|
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||||
|
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||||
|
- **Stats Library**:
|
||||||
|
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||||
|
- Documented YouTube channel filtering and video statistics in the Library.
|
||||||
|
- **Mining**:
|
||||||
|
- Documented choosing the screenshot separately in media timing review.
|
||||||
|
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||||
|
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Removed duplicate source and launcher smoke runs from the reusable CI quality gate. Every distinct test lane and failure artifact is kept.
|
||||||
|
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check. It covers compiled stats startup, the HTTP service, native SQLite, port conflicts, and cleanup.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Previous Versions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.19.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.19.6 (2026-09-04)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|
||||||
@@ -31,7 +157,7 @@
|
|||||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||||
|
|
||||||
## v0.19.5 (2026-08-30)
|
<h2>v0.19.5 (2026-08-30)</h2>
|
||||||
|
|
||||||
**Fixed**
|
**Fixed**
|
||||||
|
|
||||||
@@ -46,7 +172,7 @@
|
|||||||
- Long speech is paged instead of covering the video with a wall of text.
|
- Long speech is paged instead of covering the video with a wall of text.
|
||||||
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
|
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
|
||||||
|
|
||||||
## v0.19.4 (2026-08-25)
|
<h2>v0.19.4 (2026-08-25)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
|
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
|
||||||
@@ -87,7 +213,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## v0.19.3 (2026-08-13)
|
<h2>v0.19.3 (2026-08-13)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||||
@@ -111,7 +237,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## v0.19.2 (2026-08-04)
|
<h2>v0.19.2 (2026-08-04)</h2>
|
||||||
|
|
||||||
**Changed**
|
**Changed**
|
||||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
@@ -129,7 +255,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## v0.19.1 (2026-08-01)
|
<h2>v0.19.1 (2026-08-01)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
@@ -138,7 +264,7 @@
|
|||||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
<h2>v0.19.0 (2026-07-29)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
||||||
@@ -174,7 +300,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Previous Versions
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.18.x</summary>
|
<summary>v0.18.x</summary>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Stats server config lives under `stats`:
|
|||||||
|
|
||||||
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
||||||
|
|
||||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
||||||
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ pip install ffsubsync
|
|||||||
|
|
||||||
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
|
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
|
||||||
|
|
||||||
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
|
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots). Put mpv on `PATH` or set `mpv.executablePath` during setup. ffmpeg must be on `PATH`.
|
||||||
|
|
||||||
::: tip What is PATH?
|
::: tip What is PATH?
|
||||||
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
|
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner uses it to find ffmpeg and, unless an executable path is configured, mpv. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
|
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
|
||||||
|
|||||||
@@ -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
|
- A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported)
|
||||||
- SubMiner installed and running (see [Installation](/installation))
|
- 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
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
|
|||||||
## What happens during playback
|
## What happens during playback
|
||||||
|
|
||||||
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
|
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
|
||||||
|
- **Windows respects your mpv settings.** Casting checks `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid configured path prevents automatic startup.
|
||||||
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
|
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
|
||||||
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
|
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
|
||||||
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
|
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- `claude` (Claude Code CLI) installed, on `PATH`, and authenticated.
|
- `claude` (Claude Code CLI) installed, on `PATH`, and authenticated.
|
||||||
`changelog:build` and `changelog:prerelease-notes` invoke
|
`changelog:build` and `changelog:prerelease-notes` invoke
|
||||||
`claude -p --model sonnet` to merge and rewrite `changes/*.md` fragments into
|
`claude -p --model opus --effort medium` to merge and rewrite `changes/*.md` fragments into
|
||||||
a polished, user-facing release body. Either OAuth login (`claude /login`) or
|
a polished, user-facing release body. Either OAuth login (`claude /login`) or
|
||||||
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
|
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
|
||||||
you don't already have it.
|
you don't already have it.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
`src/config/resolve/anki-connect/`
|
`src/config/resolve/anki-connect/`
|
||||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||||
|
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
||||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ 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}"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -103,7 +102,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&api_key=${encodeURIComponent(session.accessToken)}`;
|
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
|
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
|
||||||
|
|||||||
+3
-3
@@ -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\\&api_key=${escapeShellSingle(session.accessToken)}
|
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
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\\&api_key=${escapeShellSingle(session.accessToken)}
|
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
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\\&api_key=${escapeShellSingle(session.accessToken)}
|
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
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
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.6",
|
"version": "0.20.0",
|
||||||
"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.15.3",
|
"app-builder-lib": "26.16.1",
|
||||||
"brace-expansion": "5.0.9",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.16.1",
|
||||||
"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.15.3",
|
"electron-builder": "26.16.1",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+104
-47
@@ -1,68 +1,125 @@
|
|||||||
> 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-base-version: 0.19.4 -->
|
<!-- prerelease-version: 0.20.0-beta.2; since: v0.20.0-beta.1 -->
|
||||||
|
|
||||||
|
## Changes since v0.20.0-beta.1
|
||||||
|
|
||||||
|
- Added an optional subtitle selection modal for primary and secondary mpv subtitle tracks. Enable it in Settings > Behavior, then press `g` followed by `s`. Disabling it restores mpv's subtitle selection binding.
|
||||||
|
- Single-key shortcut actions now take priority over configured multi-key sequence prefixes, conflicting sequences are disabled with a warning, and existing `y` commands remain reserved.
|
||||||
|
- Jellyfin casting and playback now honor a configured mpv executable path, allowing playback when mpv is installed outside the system PATH, and portable plugins located beside that executable are now detected.
|
||||||
|
- Fixed word-card sentence furigana falling out of sync with full stats-search context and expanded timing-review selections; stale furigana is now cleared if regeneration fails.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Library Merge & Reassignment
|
- **Japanese Subtitle Generation**:
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
|
- An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped.
|
||||||
|
- Long passages split near natural speech pauses, guided by an existing subtitle track when one is loaded, giving tighter timing and fewer repeated-word glitches.
|
||||||
|
|
||||||
- Duplicate Line Cleanup
|
- **Media Timing Review Frame Picker**:
|
||||||
- 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.
|
- 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.
|
||||||
- A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
|
- Works for local video and for seekable remote streams like Jellyfin.
|
||||||
- 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 Selection Modal**:
|
||||||
|
- An optional subtitle selection modal lets you pick primary and secondary mpv subtitle tracks without leaving the overlay.
|
||||||
|
- Enable it in Settings under Behavior, then trigger it with `g` followed by `s`; turning it off restores mpv's normal subtitle selection binding.
|
||||||
|
- Single-key shortcuts always take priority over multi-key sequences, and any conflicting sequence is disabled with a warning instead of misbehaving.
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
- Subtitle Duplication from Karaoke & Animated Signs
|
- **Jellyfin**:
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
|
||||||
- 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.
|
- Casting now honors your configured mpv executable path, so playback works and portable plugins are detected correctly when mpv isn't on PATH.
|
||||||
|
- The "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode.
|
||||||
|
- Anki cards mined from Jellyfin now get the real episode title in the misc info field instead of "Unknown media".
|
||||||
|
- Jellyfin streams no longer leak URL-derived titles or credential-bearing URLs into metadata, Anki fields, Discord presence, stats, or AniList lookups; previously cached data that had credentials in it is cleaned up automatically.
|
||||||
|
|
||||||
- Anki Media Generation
|
- **Anki & Mining**:
|
||||||
- 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`.
|
- 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.
|
||||||
- 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.
|
- Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining.
|
||||||
|
- Closing the overlay while a media timing review is still loading now properly cancels setup, restores playback if the review had paused it, and cleans up the hidden preview player.
|
||||||
|
- Word-card sentence furigana now stays in sync with the full stats-search context and expanded timing-review selections, and clears stale readings automatically if regeneration fails.
|
||||||
|
|
||||||
- Character Dictionary Performance & Notifications
|
- **Settings**:
|
||||||
- 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.
|
- 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.
|
||||||
- Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
|
- 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.
|
||||||
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
|
|
||||||
|
|
||||||
- Overlay Reliability
|
- **Overlay**:
|
||||||
- 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.
|
- 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.
|
||||||
- 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."
|
- Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates.
|
||||||
- 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 a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down.
|
||||||
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
|
|
||||||
|
|
||||||
- Stats Dashboard
|
- **Stats**:
|
||||||
- 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.
|
- Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills.
|
||||||
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups.
|
- 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.
|
||||||
- Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
|
|
||||||
|
|
||||||
- Linux Launcher Thumbnails
|
- **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero.
|
||||||
- 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(stats): add library entry merge and episode move by @ksyasuda in #190
|
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
|
||||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
|
||||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
|
||||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
|
||||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
|
||||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
- build(release): reduce package size and report release sizes by @ksyasuda in #244
|
||||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
|
||||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
|
||||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
|
||||||
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
|
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
|
||||||
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
|
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
|
||||||
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
|
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
|
||||||
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
|
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
|
||||||
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
|
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
|
||||||
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
|
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
|
||||||
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
|
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
|
||||||
|
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
|
||||||
|
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
|
||||||
|
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
|
||||||
|
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
|
||||||
|
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
|
||||||
|
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
|
||||||
|
- fix(jellyfin): support modern authentication by @ksyasuda in #264
|
||||||
|
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
|
||||||
|
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
|
||||||
|
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
|
||||||
|
- @aalhendi made their first contribution in #254
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
## Highlights
|
||||||
|
### Added
|
||||||
|
- **Japanese Subtitle Generation**:
|
||||||
|
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||||
|
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||||
|
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||||
|
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||||
|
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||||
|
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||||
|
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||||
|
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||||
|
- **Subtitle Selection Modal**:
|
||||||
|
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||||
|
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||||
|
- Single-key actions take priority over configured key sequence prefixes.
|
||||||
|
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||||
|
- **Subtitle Sidebar Copy**:
|
||||||
|
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||||
|
- Selecting text does not seek playback and does not require mining a card.
|
||||||
|
- **Media Timing Review Screenshot Picker**:
|
||||||
|
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||||
|
- Step through decoded frames one at a time to get the exact frame you want.
|
||||||
|
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||||
|
- **mpv Keybindings in the Overlay**:
|
||||||
|
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||||
|
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||||
|
- These bindings apply only to the current session and are not listed in the help menu.
|
||||||
|
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||||
|
- **TMDB Live-Action Library**:
|
||||||
|
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||||
|
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||||
|
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||||
|
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||||
|
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||||
|
- **YouTube Library Kind**:
|
||||||
|
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||||
|
- New All Titles, Anime, and YouTube Library filters.
|
||||||
|
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||||
|
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Launcher Uses Bundled Bun**:
|
||||||
|
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||||
|
- Recognized legacy launchers migrate automatically.
|
||||||
|
- Windows gets a `subminer.cmd` launcher download.
|
||||||
|
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||||
|
- **Faster Sync Transfers**:
|
||||||
|
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||||
|
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||||
|
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||||
|
- Older peers still work without the upload cache.
|
||||||
|
- Transfers abort after 30 minutes.
|
||||||
|
- **Stats Server Request Safety**:
|
||||||
|
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||||
|
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||||
|
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||||
|
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||||
|
- **Smaller Downloads**:
|
||||||
|
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||||
|
- All windows now share one Japanese UI font.
|
||||||
|
- Release builds publish package size reports that compare against the previous release.
|
||||||
|
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Jellyfin 12 Compatibility**:
|
||||||
|
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||||
|
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, 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.
|
||||||
|
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||||
|
- **Jellyfin Privacy and Playback**:
|
||||||
|
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||||
|
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||||
|
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||||
|
- **Anki Mining**:
|
||||||
|
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||||
|
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||||
|
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||||
|
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||||
|
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||||
|
- **Stats Server Stability**:
|
||||||
|
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||||
|
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||||
|
- Shutdown waits only a limited time for active requests to finish.
|
||||||
|
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||||
|
- **Subtitle Sidebar**:
|
||||||
|
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||||
|
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||||
|
- **Settings Save Feedback**:
|
||||||
|
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||||
|
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||||
|
- **Overlay Windows**:
|
||||||
|
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||||
|
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||||
|
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||||
|
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||||
|
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||||
|
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||||
|
- **Jellyfin**:
|
||||||
|
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||||
|
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||||
|
- **Stats Library**:
|
||||||
|
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||||
|
- Documented YouTube channel filtering and video statistics in the Library.
|
||||||
|
- **Mining**:
|
||||||
|
- Documented choosing the screenshot separately in media timing review.
|
||||||
|
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||||
|
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
|
||||||
|
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
|
||||||
|
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
|
||||||
|
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
|
||||||
|
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
|
||||||
|
- build(release): reduce package size and report release sizes by @ksyasuda in #244
|
||||||
|
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
|
||||||
|
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
|
||||||
|
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
|
||||||
|
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
|
||||||
|
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
|
||||||
|
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
|
||||||
|
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
|
||||||
|
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
|
||||||
|
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
|
||||||
|
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
|
||||||
|
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
|
||||||
|
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
|
||||||
|
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
|
||||||
|
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
|
||||||
|
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
|
||||||
|
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
|
||||||
|
- fix(jellyfin): support modern authentication by @ksyasuda in #264
|
||||||
|
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
|
||||||
|
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
|
||||||
|
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
|
||||||
|
- @aalhendi made their first contribution in #254
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See the README and docs/installation guide for full setup steps.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Linux: `SubMiner.AppImage`
|
||||||
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
|
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||||
|
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||||
|
|
||||||
|
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||||
|
|
||||||
|
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||||
@@ -436,7 +436,9 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
|
|||||||
const CLAUDE_CLI_ARGS = [
|
const CLAUDE_CLI_ARGS = [
|
||||||
'-p',
|
'-p',
|
||||||
'--model',
|
'--model',
|
||||||
'sonnet',
|
'opus',
|
||||||
|
'--effort',
|
||||||
|
'medium',
|
||||||
'--permission-mode',
|
'--permission-mode',
|
||||||
'bypassPermissions',
|
'bypassPermissions',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ function listFiles(root, prefix = '') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asar resolves lookups with the platform separator, so stat with the listed
|
||||||
|
// native path and only normalize the reported name.
|
||||||
function listAppFiles(archive) {
|
function listAppFiles(archive) {
|
||||||
return asar.listPackage(archive).flatMap((entry) => {
|
return asar.listPackage(archive).flatMap((entry) => {
|
||||||
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
|
const native = entry.replace(/^[\\/]/, '');
|
||||||
const stat = asar.statFile(archive, name);
|
const stat = asar.statFile(archive, native);
|
||||||
|
const name = native.replaceAll('\\', '/');
|
||||||
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
|
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,7 +132,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, `dist/${ui}/style.css`).toString();
|
const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString();
|
||||||
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
|
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
|
|||||||
@@ -134,10 +134,12 @@ 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'].map((name) => ({
|
['main.js', 'native.node', 'dist/ai/client.js'].map((name) => ({
|
||||||
path: name,
|
path: name,
|
||||||
type: 'file',
|
type: 'file',
|
||||||
unpacked: name.endsWith('.node'),
|
unpacked: name.endsWith('.node'),
|
||||||
@@ -148,6 +150,7 @@ 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),
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ 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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// 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');
|
||||||
@@ -21,6 +22,39 @@ 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'));
|
||||||
@@ -50,10 +84,17 @@ 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({ urls: ['file://*/*'] }, (details) => {
|
session.defaultSession.webRequest.onErrorOccurred(
|
||||||
if (details.error !== 'net::ERR_ABORTED')
|
{ urls: ['file://*/*', 'http://127.0.0.1/*'] },
|
||||||
failedRequests.push(`${details.url}: ${details.error}`);
|
(details) => {
|
||||||
});
|
// Chromium probes the cache before fetching @font-face fonts; an uncached
|
||||||
|
// font reports ERR_CACHE_MISS and is then fetched normally.
|
||||||
|
if (!['net::ERR_ABORTED', 'net::ERR_CACHE_MISS'].includes(details.error))
|
||||||
|
failedRequests.push(`${details.url}: ${details.error}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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,
|
||||||
@@ -63,10 +104,12 @@ async function smoke() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await win.loadFile(
|
if (ui === 'stats') {
|
||||||
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
|
await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`);
|
||||||
);
|
// Let in-flight font requests settle before the window goes away.
|
||||||
if (ui !== 'stats') {
|
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
|
||||||
|
} 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'))`,
|
||||||
);
|
);
|
||||||
@@ -76,6 +119,7 @@ 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.',
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
|
|||||||
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
+15
-1
@@ -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=')) {
|
if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
|
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
|
||||||
@@ -243,6 +243,9 @@ export class AnkiIntegration {
|
|||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
|
private generateSentenceFuriganaCallback:
|
||||||
|
| ((text: string, highlightedText?: string) => Promise<string | null>)
|
||||||
|
| null = null;
|
||||||
private mediaTimingReviewCallback:
|
private mediaTimingReviewCallback:
|
||||||
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
||||||
| null = null;
|
| null = null;
|
||||||
@@ -666,6 +669,13 @@ export class AnkiIntegration {
|
|||||||
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
||||||
processSentenceFurigana: (sentenceFurigana, noteFields) =>
|
processSentenceFurigana: (sentenceFurigana, noteFields) =>
|
||||||
this.processSentenceFurigana(sentenceFurigana, noteFields),
|
this.processSentenceFurigana(sentenceFurigana, noteFields),
|
||||||
|
generateSentenceFurigana: async (text, noteFields) =>
|
||||||
|
this.generateSentenceFuriganaCallback?.(
|
||||||
|
text,
|
||||||
|
this.config.behavior?.highlightWord === false
|
||||||
|
? undefined
|
||||||
|
: this.getSentenceHighlightText(noteFields),
|
||||||
|
) ?? null,
|
||||||
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
|
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
|
||||||
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
||||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
@@ -1783,6 +1793,10 @@ export class AnkiIntegration {
|
|||||||
this.consumeSubtitleMiningContextCallback = callback;
|
this.consumeSubtitleMiningContextCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSentenceFuriganaGenerator(callback: typeof this.generateSentenceFuriganaCallback): void {
|
||||||
|
this.generateSentenceFuriganaCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
setMediaTimingReviewCallback(
|
setMediaTimingReviewCallback(
|
||||||
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
|
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -166,13 +166,14 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
|
|||||||
|
|
||||||
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getCurrentSubtitleText = () => 'tokugi';
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
noteId: 42,
|
noteId: 42,
|
||||||
fields: {
|
fields: {
|
||||||
Expression: { value: 'tokugi' },
|
Expression: { value: 'tokugi' },
|
||||||
Sentence: { value: '' },
|
Sentence: { value: 'tokugi' },
|
||||||
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
|
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -184,7 +185,7 @@ test('NoteUpdateWorkflow updates sentence furigana when highlight processor chan
|
|||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
assert.equal(harness.updates.length, 1);
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
Sentence: 'subtitle-text',
|
Sentence: 'tokugi',
|
||||||
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
|
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -776,3 +777,63 @@ test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails',
|
|||||||
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
|
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
|
||||||
assert.ok(harness.warnings.length === 0);
|
assert.ok(harness.warnings.length === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const outcome of ['success', 'unavailable', 'throws'] as const) {
|
||||||
|
test(`NoteUpdateWorkflow regenerates expanded furigana (${outcome})`, async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.client.notesInfo = async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '<b>猫</b>を見た。' },
|
||||||
|
SentenceFurigana: { value: ' 猫[ねこ]を 見[み]た。' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
harness.deps.captureSubtitleMediaContext = () => ({
|
||||||
|
source: 'overlay',
|
||||||
|
text: '猫を見た。',
|
||||||
|
startTime: 4,
|
||||||
|
endTime: 6,
|
||||||
|
});
|
||||||
|
harness.deps.reviewMediaTiming = async () => ({
|
||||||
|
action: 'confirm',
|
||||||
|
text: '猫を見た。犬もいた。',
|
||||||
|
startTime: 2,
|
||||||
|
endTime: 8,
|
||||||
|
});
|
||||||
|
harness.deps.generateSentenceFurigana = async (text, fields) => {
|
||||||
|
assert.equal(text, '猫を見た。犬もいた。');
|
||||||
|
assert.equal(fields.expression, '猫');
|
||||||
|
if (outcome === 'throws') throw new Error('parser unavailable');
|
||||||
|
return outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : null;
|
||||||
|
};
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
assert.equal(harness.updates[0]?.fields.Sentence, '猫を見た。犬もいた。');
|
||||||
|
assert.equal(
|
||||||
|
harness.updates[0]?.fields.SentenceFurigana,
|
||||||
|
outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : '',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow preserves native furigana formatting when sentence context is unchanged', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.client.notesInfo = async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '<b>猫</b>を見た。' },
|
||||||
|
SentenceFurigana: { value: '<ruby>猫<rt>ねこ</rt></ruby>を見た。' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
harness.deps.getCurrentSubtitleText = () => '猫を見た。';
|
||||||
|
harness.deps.generateSentenceFurigana = async () => {
|
||||||
|
assert.fail('unchanged sentence must keep native formatting');
|
||||||
|
};
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
assert.equal(harness.updates[0]?.fields.SentenceFurigana, undefined);
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
sentenceFurigana: string,
|
sentenceFurigana: string,
|
||||||
noteFields: Record<string, string>,
|
noteFields: Record<string, string>,
|
||||||
) => string;
|
) => string;
|
||||||
|
generateSentenceFurigana?: (
|
||||||
|
text: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
) => Promise<string | null>;
|
||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
@@ -282,7 +286,27 @@ export class NoteUpdateWorkflow {
|
|||||||
const existingSentenceFurigana = sentenceFuriganaField
|
const existingSentenceFurigana = sentenceFuriganaField
|
||||||
? noteInfo.fields[sentenceFuriganaField]?.value || ''
|
? noteInfo.fields[sentenceFuriganaField]?.value || ''
|
||||||
: '';
|
: '';
|
||||||
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
|
const sentenceChanged =
|
||||||
|
sentenceField &&
|
||||||
|
currentSubtitleText &&
|
||||||
|
normalizeSubtitleContextText(currentSubtitleText) !==
|
||||||
|
normalizeSubtitleContextText(noteInfo.fields[sentenceField]?.value ?? '');
|
||||||
|
if (sentenceFuriganaField && sentenceChanged) {
|
||||||
|
let furigana: string | null = null;
|
||||||
|
try {
|
||||||
|
furigana =
|
||||||
|
(await this.deps.generateSentenceFurigana?.(currentSubtitleText, fields)) ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logWarn('Failed to regenerate sentence furigana:', error);
|
||||||
|
}
|
||||||
|
// Empty furigana lets card templates fall back to the updated Sentence field.
|
||||||
|
updatedFields[sentenceFuriganaField] = furigana ?? '';
|
||||||
|
updatePerformed = true;
|
||||||
|
} else if (
|
||||||
|
sentenceFuriganaField &&
|
||||||
|
existingSentenceFurigana &&
|
||||||
|
this.deps.processSentenceFurigana
|
||||||
|
) {
|
||||||
const processedSentenceFurigana = this.deps.processSentenceFurigana(
|
const processedSentenceFurigana = this.deps.processSentenceFurigana(
|
||||||
existingSentenceFurigana,
|
existingSentenceFurigana,
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { formatSentenceFurigana } from './sentence-furigana';
|
||||||
|
|
||||||
|
const parsed = [
|
||||||
|
{
|
||||||
|
source: 'scanning-parser',
|
||||||
|
content: [
|
||||||
|
[{ text: '猫', reading: 'ねこ' }],
|
||||||
|
[{ text: 'を' }],
|
||||||
|
[{ text: '見', reading: 'み' }, { text: 'た' }],
|
||||||
|
[{ text: '。\n' }],
|
||||||
|
[{ text: '犬', reading: 'いぬ' }],
|
||||||
|
[{ text: 'もいた。' }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('formats the complete expanded sentence with readings and the mined word highlighted', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSentenceFurigana('猫を見た。\n犬もいた。', parsed, '猫'),
|
||||||
|
'<b> 猫[ねこ]</b>を 見[み]た。\n 犬[いぬ]もいた。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects headword-only and malformed parse results instead of saving partial furigana', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSentenceFurigana('猫を見た。', [
|
||||||
|
{ source: 'scanning-parser', content: [[{ text: '猫', reading: 'ねこ' }]] },
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatSentenceFurigana('猫', [
|
||||||
|
{ source: 'scanning-parser', content: [[{ text: '猫', reading: 42 }]] },
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes literal markup and annotation delimiters and leaves kana unannotated', () => {
|
||||||
|
const text = '<猫> [メモ]';
|
||||||
|
assert.equal(
|
||||||
|
formatSentenceFurigana(text, [
|
||||||
|
{
|
||||||
|
source: 'scanning-parser',
|
||||||
|
content: [
|
||||||
|
[{ text: '<' }],
|
||||||
|
[{ text: '猫', reading: 'ねこ' }],
|
||||||
|
[{ text: '> [' }],
|
||||||
|
[{ text: 'メモ', reading: 'めも' }],
|
||||||
|
[{ text: ']' }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
'< 猫[ねこ]> [メモ]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('highlights the mined word without bolding the rest of a dictionary phrase', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSentenceFurigana(
|
||||||
|
'行儀を直して',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
[
|
||||||
|
{ text: '行儀', reading: 'ぎょうぎ' },
|
||||||
|
{ text: 'を' },
|
||||||
|
{ text: '直', reading: 'なお' },
|
||||||
|
{ text: 'して' },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'行儀',
|
||||||
|
),
|
||||||
|
'<b> 行儀[ぎょうぎ]</b>を 直[なお]して',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
type Segment = { text: string; reading?: string };
|
||||||
|
|
||||||
|
function readGroups(value: unknown): Segment[][] | null {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
const groups: Segment[][] = [];
|
||||||
|
const rawGroups: unknown[] = value;
|
||||||
|
for (const group of rawGroups) {
|
||||||
|
if (!Array.isArray(group)) return null;
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
const rawSegments: unknown[] = group;
|
||||||
|
for (const segment of rawSegments) {
|
||||||
|
if (
|
||||||
|
typeof segment !== 'object' ||
|
||||||
|
segment === null ||
|
||||||
|
!('text' in segment) ||
|
||||||
|
typeof segment.text !== 'string' ||
|
||||||
|
('reading' in segment &&
|
||||||
|
segment.reading !== undefined &&
|
||||||
|
typeof segment.reading !== 'string')
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
segments.push({
|
||||||
|
text: segment.text,
|
||||||
|
reading:
|
||||||
|
'reading' in segment && typeof segment.reading === 'string' ? segment.reading : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groups.push(segments);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\[/g, '[')
|
||||||
|
.replace(/\]/g, ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept a complete parse, so a lookup of just the headword cannot replace sentence context.
|
||||||
|
export function formatSentenceFurigana(
|
||||||
|
text: string,
|
||||||
|
results: unknown[] | null,
|
||||||
|
highlightedText?: string,
|
||||||
|
): string | null {
|
||||||
|
for (const result of results ?? []) {
|
||||||
|
if (typeof result !== 'object' || result === null || !('content' in result)) continue;
|
||||||
|
const groups = readGroups(result.content);
|
||||||
|
if (
|
||||||
|
!groups ||
|
||||||
|
groups
|
||||||
|
.flat()
|
||||||
|
.map((segment) => segment.text)
|
||||||
|
.join('') !== text
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const highlights: number[] = [];
|
||||||
|
if (highlightedText) {
|
||||||
|
let start = text.indexOf(highlightedText);
|
||||||
|
while (start >= 0) {
|
||||||
|
highlights.push(start);
|
||||||
|
start = text.indexOf(highlightedText, start + highlightedText.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offset = 0;
|
||||||
|
let bold = false;
|
||||||
|
let output = '';
|
||||||
|
for (const { text: surface, reading } of groups.flat()) {
|
||||||
|
const start = offset;
|
||||||
|
offset += surface.length;
|
||||||
|
const highlighted = highlights.some(
|
||||||
|
(position) => position < offset && position + (highlightedText?.length ?? 0) > start,
|
||||||
|
);
|
||||||
|
if (highlighted !== bold) output += highlighted ? '<b>' : '</b>';
|
||||||
|
bold = highlighted;
|
||||||
|
const escaped = escapeText(surface);
|
||||||
|
output +=
|
||||||
|
reading && reading !== surface && /[\p{Script=Han}々]/u.test(surface)
|
||||||
|
? ` ${escaped}[${escapeText(reading)}]`
|
||||||
|
: escaped;
|
||||||
|
}
|
||||||
|
return output + (bold ? '</b>' : '');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -4321,3 +4321,101 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de
|
|||||||
assert.equal(fetches, 1);
|
assert.equal(fetches, 1);
|
||||||
assert.deepEqual(assignments, [1]);
|
assert.deepEqual(assignments, [1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) {
|
||||||
|
it(`stats word mining updates full sentence furigana without media (${outcome})`, async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
let calls = 0;
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
media: { generateAudio: false, generateImage: false },
|
||||||
|
},
|
||||||
|
addYomitanNote: async () => 12345,
|
||||||
|
generateSentenceFurigana: async (text, word) => {
|
||||||
|
calls++;
|
||||||
|
assert.equal(text, '猫を見た。');
|
||||||
|
assert.equal(word, '猫');
|
||||||
|
if (outcome === 'throws') throw new Error('parser unavailable');
|
||||||
|
return outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。' : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1000,
|
||||||
|
endMs: 2000,
|
||||||
|
sentence: '猫を見た。',
|
||||||
|
word: '猫',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const fields = requests.find((request) => request.action === 'updateNoteFields')?.params
|
||||||
|
?.note?.fields;
|
||||||
|
assert.equal(fields?.Sentence, '<b>猫</b>を見た。');
|
||||||
|
assert.equal(
|
||||||
|
fields?.sentencefurigana,
|
||||||
|
outcome === 'no-field'
|
||||||
|
? undefined
|
||||||
|
: outcome === 'success'
|
||||||
|
? '<b> 猫[ねこ]</b>を 見[み]た。'
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
assert.equal(calls, outcome === 'no-field' ? 0 : 1);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Sentence: { value: '猫' },
|
||||||
|
...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stats word mining skips furigana highlighting when highlightWord is disabled', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (_requests, url) => {
|
||||||
|
const highlights: Array<string | undefined> = [];
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
media: { generateAudio: false, generateImage: false },
|
||||||
|
behavior: { highlightWord: false },
|
||||||
|
},
|
||||||
|
addYomitanNote: async () => 12345,
|
||||||
|
generateSentenceFurigana: async (_text, highlightedText) => {
|
||||||
|
highlights.push(highlightedText);
|
||||||
|
return ' 猫[ねこ]を 見[み]た。';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1000,
|
||||||
|
endMs: 2000,
|
||||||
|
sentence: '猫を見た。',
|
||||||
|
word: '猫',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(highlights, [undefined]);
|
||||||
|
},
|
||||||
|
{ notesInfoFields: { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3174,6 +3174,7 @@ test('Jellyfin metadata cleanup requires both an API key and a stream marker', a
|
|||||||
{ filename: 'stream?api_key=secret', leaked: true },
|
{ filename: 'stream?api_key=secret', leaked: true },
|
||||||
{ filename: '/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,6 +376,7 @@ 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')) {
|
if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) {
|
||||||
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 '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%'
|
||||||
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=%'
|
WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%'
|
||||||
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=%'
|
parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%'
|
||||||
OR lower(parsed_basename) LIKE '%api key%'
|
OR lower(parsed_basename) LIKE '%api key%'
|
||||||
OR parsed_title LIKE '%api_key=%'
|
OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%'
|
||||||
OR lower(parsed_title) LIKE '%api key%'
|
OR lower(parsed_title) LIKE '%api key%'
|
||||||
OR parse_metadata_json LIKE '%api_key=%'
|
OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%'
|
||||||
OR lower(parse_metadata_json) LIKE '%api key%'
|
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=%'
|
metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%'
|
||||||
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=%'
|
video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%'
|
||||||
OR lower(video_key) LIKE '%api key%'
|
OR lower(video_key) LIKE '%api key%'
|
||||||
OR source_url LIKE '%api_key=%'
|
OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%'
|
||||||
OR lower(source_url) LIKE '%api key%'
|
OR lower(source_url) LIKE '%api key%'
|
||||||
OR canonical_title LIKE '%api_key=%'
|
OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%'
|
||||||
OR lower(canonical_title) LIKE '%api key%'
|
OR lower(canonical_title) LIKE '%api key%'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ 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]) {
|
||||||
@@ -58,7 +69,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?api_key=token-1&deviceId=device-1');
|
assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1');
|
||||||
const socket = new FakeWebSocket();
|
const socket = new FakeWebSocket();
|
||||||
sockets.push(socket);
|
sockets.push(socket);
|
||||||
return socket as unknown as any;
|
return socket as unknown as any;
|
||||||
@@ -99,7 +110,8 @@ 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.ok(seenHeaders[0]!['X-Emby-Authorization']);
|
assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false);
|
||||||
|
assert.equal('X-Emby-Token' in seenHeaders[0]!, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
|
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
|
||||||
@@ -355,3 +367,149 @@ test('advertiseNow validates server registration using Sessions endpoint', async
|
|||||||
assert.equal(ok, true);
|
assert.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"}']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ 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 {
|
||||||
@@ -77,6 +90,9 @@ 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 {
|
||||||
@@ -196,6 +212,12 @@ 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;
|
||||||
@@ -233,6 +255,12 @@ 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,
|
||||||
@@ -250,6 +278,7 @@ 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;
|
||||||
@@ -298,12 +327,16 @@ 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) => {
|
||||||
this.handleInboundMessage(rawData);
|
if (this.socket !== socket || !this.running) return;
|
||||||
|
this.lastInboundAtMs = this.now();
|
||||||
|
this.handleInboundMessage(socket, rawData);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
@@ -311,6 +344,7 @@ 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?.();
|
||||||
@@ -323,6 +357,51 @@ 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,
|
||||||
@@ -342,7 +421,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('api_key', this.accessToken);
|
socketUrl.searchParams.set('ApiKey', this.accessToken);
|
||||||
socketUrl.searchParams.set('deviceId', this.deviceId);
|
socketUrl.searchParams.set('deviceId', this.deviceId);
|
||||||
return socketUrl.toString();
|
return socketUrl.toString();
|
||||||
}
|
}
|
||||||
@@ -350,8 +429,6 @@ 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);
|
||||||
@@ -375,8 +452,6 @@ 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;
|
||||||
@@ -398,21 +473,41 @@ 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 {
|
} catch (error) {
|
||||||
|
this.noteRequestOutcome(path, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleInboundMessage(rawData: unknown): void {
|
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||||
|
// log without a warning per progress tick.
|
||||||
|
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||||
|
if (failure === null) {
|
||||||
|
this.failedRequestPaths.delete(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.failedRequestPaths.has(path)) return;
|
||||||
|
this.failedRequestPaths.add(path);
|
||||||
|
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||||
const message = parseInboundMessage(rawData);
|
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);
|
||||||
|
|||||||
@@ -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('api_key'), 'token');
|
assert.equal(url.searchParams.get('ApiKey'), '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',
|
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token',
|
||||||
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?api_key=token',
|
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tracks[1]!.deliveryUrl,
|
tracks[1]!.deliveryUrl,
|
||||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
|
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token',
|
||||||
);
|
);
|
||||||
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
|
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('api_key'), 'token');
|
assert.equal(url.searchParams.get('ApiKey'), '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?api_key=token',
|
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
@@ -789,3 +789,67 @@ 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ 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,
|
||||||
@@ -146,9 +156,7 @@ 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}/`);
|
||||||
if (!resolved.searchParams.has('api_key')) {
|
setApiKeyParam(resolved, session.accessToken);
|
||||||
resolved.searchParams.set('api_key', session.accessToken);
|
|
||||||
}
|
|
||||||
return resolved.toString();
|
return resolved.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +179,7 @@ function resolveDeliveryUrl(
|
|||||||
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
|
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
|
||||||
`${session.serverUrl}/`,
|
`${session.serverUrl}/`,
|
||||||
);
|
);
|
||||||
if (!fallback.searchParams.has('api_key')) {
|
setApiKeyParam(fallback, session.accessToken);
|
||||||
fallback.searchParams.set('api_key', session.accessToken);
|
|
||||||
}
|
|
||||||
return fallback.toString();
|
return fallback.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +203,6 @@ 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,
|
||||||
@@ -221,7 +226,7 @@ function createDirectPlayUrl(
|
|||||||
): string {
|
): string {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
static: 'true',
|
static: 'true',
|
||||||
api_key: session.accessToken,
|
ApiKey: session.accessToken,
|
||||||
MediaSourceId: ensureString(mediaSource.Id),
|
MediaSourceId: ensureString(mediaSource.Id),
|
||||||
});
|
});
|
||||||
if (mediaSource.LiveStreamId) {
|
if (mediaSource.LiveStreamId) {
|
||||||
@@ -245,9 +250,7 @@ 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}`);
|
||||||
if (!url.searchParams.has('api_key')) {
|
setApiKeyParam(url, session.accessToken);
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -261,7 +264,7 @@ function createTranscodeUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
api_key: session.accessToken,
|
ApiKey: session.accessToken,
|
||||||
MediaSourceId: ensureString(mediaSource.Id),
|
MediaSourceId: ensureString(mediaSource.Id),
|
||||||
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
|
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
|
||||||
TranscodingContainer: 'ts',
|
TranscodingContainer: 'ts',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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,
|
||||||
@@ -85,6 +86,7 @@ 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'],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
|
|||||||
export const MPV_REQUEST_ID_PAUSE = 123;
|
export const MPV_REQUEST_ID_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;
|
||||||
@@ -335,14 +336,18 @@ 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') {
|
||||||
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
|
applyMediaTitle(deps, msg.data);
|
||||||
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();
|
||||||
@@ -467,6 +472,8 @@ 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();
|
||||||
@@ -557,6 +564,17 @@ 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;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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';
|
||||||
|
|
||||||
@@ -135,9 +136,15 @@ 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', async () => {
|
test('MpvIpcClient clears cached media title when media path changes and reads it back', async () => {
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
const 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',
|
||||||
@@ -148,11 +155,33 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
|
|||||||
await invokeHandleMessage(client, {
|
await invokeHandleMessage(client, {
|
||||||
event: 'property-change',
|
event: 'property-change',
|
||||||
name: 'path',
|
name: 'path',
|
||||||
data: '/tmp/new-episode.mkv',
|
data: 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv');
|
assert.equal(
|
||||||
|
client.currentVideoPath,
|
||||||
|
'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
|
||||||
|
);
|
||||||
assert.equal(client.currentMediaTitle, null);
|
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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { StatsMiningRouteOptions } from './stats-server/mining-support';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
@@ -132,6 +133,7 @@ export interface StatsServerConfig {
|
|||||||
anilistRateLimiter?: AnilistRateLimiter;
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
tmdbClient?: TmdbClient;
|
tmdbClient?: TmdbClient;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
|
generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana'];
|
||||||
resolveAnkiNoteId?: (noteId: number) => number;
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||||
}
|
}
|
||||||
@@ -155,6 +157,7 @@ export function createStatsApp(
|
|||||||
anilistRateLimiter?: AnilistRateLimiter;
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
tmdbClient?: TmdbClient;
|
tmdbClient?: TmdbClient;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
|
generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana'];
|
||||||
resolveAnkiNoteId?: (noteId: number) => number;
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||||
createMediaGenerator?: () => StatsServerMediaGenerator;
|
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||||
@@ -191,6 +194,7 @@ export async function startStatsServerWithRuntime(
|
|||||||
anilistRateLimiter: config.anilistRateLimiter,
|
anilistRateLimiter: config.anilistRateLimiter,
|
||||||
tmdbClient: config.tmdbClient,
|
tmdbClient: config.tmdbClient,
|
||||||
addYomitanNote: config.addYomitanNote,
|
addYomitanNote: config.addYomitanNote,
|
||||||
|
generateSentenceFurigana: config.generateSentenceFurigana,
|
||||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||||
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
getStatsDirectMiningAudioFieldNames,
|
getStatsDirectMiningAudioFieldNames,
|
||||||
getStatsWordMiningAudioFieldName,
|
getStatsWordMiningAudioFieldName,
|
||||||
resolveStatsNoteFieldName,
|
resolveStatsNoteFieldName,
|
||||||
shouldUseStatsLapisKikuCardFields,
|
|
||||||
statsMiningLogger,
|
statsMiningLogger,
|
||||||
type StatsMiningRouteOptions,
|
type StatsMiningRouteOptions,
|
||||||
type StatsServerNoteInfo,
|
type StatsServerNoteInfo,
|
||||||
@@ -228,19 +227,11 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
|
|
||||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
let noteInfo: StatsServerNoteInfo | null = null;
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
if (
|
try {
|
||||||
audioBuffer ||
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
(syncAnimatedImageToWordAudio && generateImage) ||
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
} catch (error) {
|
||||||
) {
|
errors.push(`note fields: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
try {
|
|
||||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
|
||||||
noteInfo = noteInfoResult[0] ?? null;
|
|
||||||
} catch (err) {
|
|
||||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
|
||||||
errors.push(`image: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||||
try {
|
try {
|
||||||
@@ -272,6 +263,24 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
|
const furiganaFieldName = noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, 'SentenceFurigana')
|
||||||
|
: null;
|
||||||
|
if (furiganaFieldName) {
|
||||||
|
let furigana: string | null = null;
|
||||||
|
try {
|
||||||
|
furigana =
|
||||||
|
(await options?.generateSentenceFurigana?.(
|
||||||
|
sentence,
|
||||||
|
ankiConfig.behavior?.highlightWord === false ? undefined : word,
|
||||||
|
)) ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
statsMiningLogger.warn('Failed to generate sentence furigana:', error);
|
||||||
|
}
|
||||||
|
mediaFields[furiganaFieldName] = furigana ?? '';
|
||||||
|
if (furigana === null)
|
||||||
|
errors.push('furigana: unavailable; using the full sentence without readings');
|
||||||
|
}
|
||||||
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
|
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type StatsMiningRouteOptions = {
|
|||||||
input: RetimedSecondarySubtitleInput,
|
input: RetimedSecondarySubtitleInput,
|
||||||
) => Promise<string> | string;
|
) => Promise<string> | string;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
|
generateSentenceFurigana?: (text: string, highlightedText?: string) => Promise<string | null>;
|
||||||
createMediaGenerator?: () => StatsServerMediaGenerator;
|
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||||
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
||||||
nowMs?: () => number;
|
nowMs?: () => number;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { generateSentenceFurigana } from './sentence-furigana';
|
||||||
|
import { createDeps, runInjectedYomitanScript } from './yomitan-scan-test-harness';
|
||||||
|
|
||||||
|
test('generates sentence readings through the parser runtime bridge', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
const deps = createDeps((script) =>
|
||||||
|
runInjectedYomitanScript(script, (action, params) => {
|
||||||
|
requests.push(action);
|
||||||
|
if (action === 'optionsGetFull')
|
||||||
|
return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 20 } } }] };
|
||||||
|
assert.equal(action, 'parseText');
|
||||||
|
assert.ok(typeof params === 'object' && params !== null && 'text' in params);
|
||||||
|
assert.equal(params.text, '猫がいる。');
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: 'scanning-parser',
|
||||||
|
content: [[{ text: '猫', reading: 'ねこ' }], [{ text: 'がいる。', reading: '' }]],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await generateSentenceFurigana('猫がいる。', '猫', deps, { error: assert.fail }),
|
||||||
|
'<b> 猫[ねこ]</b>がいる。',
|
||||||
|
);
|
||||||
|
assert.ok(requests.includes('parseText'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a stalled parser cannot indefinitely block sentence and media updates',
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const deps = createDeps(() => new Promise<never>(() => {}));
|
||||||
|
assert.equal(
|
||||||
|
await generateSentenceFurigana('猫', undefined, deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
warn: (message) => warnings.push(message),
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user