mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbc36dc41
|
||
|
|
e93b696e71
|
||
|
|
7c161723cd | ||
|
|
b0fb39a8f5 | ||
|
|
a7a302bdd0 |
@@ -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
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Documented the bundled-runtime launcher install, legacy launcher migration, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices (installed under `/usr/share/licenses/subminer-bin` by the AUR package and included in `subminer-assets.tar.gz`).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Every installed and downloadable launcher now uses the Bun runtime bundled with SubMiner instead of a system Bun. Recognized legacy launchers migrate automatically, Windows gets a `subminer.cmd` download, and first-run setup is reduced to a single optional launcher control with runtime repair guidance shown only when needed.
|
|
||||||
@@ -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,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: sync
|
|
||||||
|
|
||||||
- Sync now uses compressed, incremental rsync transfers between compatible macOS and Linux machines, caching the last received snapshot per peer to cut traffic on later syncs. Machines without compatible rsync (including Windows) fall back to compressed scp, and older peers still work without the upload cache. Transfers abort after 30 minutes.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Added `ankiConnect.fields.wordAudio` so word audio is read separately from the sentence-audio destination, fixing animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`.
|
|
||||||
@@ -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,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Jellyfin streams no longer leak URL-derived titles or credential-bearing stream URLs into metadata lookups, Anki source fields, Discord presence, stats identities, or AniList retry keys. Previously cached credential-bearing parser metadata is cleaned up without touching watch history or library assignments.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Jellyfin playback, subtitle, artwork, and remote-control URLs now authenticate with the `ApiKey` query parameter instead of the legacy `X-Emby-*` headers, so the integration works on Jellyfin 12 where legacy authorization is disabled by default.
|
|
||||||
- "Play on SubMiner" keeps working on Jellyfin 12: the cast-target websocket answers keep-alive requests and reconnects when the server stops replying instead of silently dying after about a minute.
|
|
||||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
|
||||||
- Anki cards mined from Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
|
||||||
@@ -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,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Stats server port conflicts are reported through status notifications instead of crashing SubMiner. Startup and shutdown are also more robust: concurrent startup requests are shared, background stop no longer disconnects foreground dashboards, and shutdown bounds how long it waits for active requests.
|
|
||||||
@@ -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,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Documented subtitle generation: model choice, VAD behavior, reference-track guided splitting, fallback behavior, and known limits.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Generate Japanese SRT subtitles locally with whisper.cpp from a modal (Ctrl+Shift+G), the empty subtitle sidebar's generation button, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv.
|
|
||||||
- Pick an official multilingual model (including quantized variants) with size and accuracy guidance and download it in-app, or point Settings at an existing model. `large-v3-turbo` is recommended when CUDA support is detected, `small` otherwise. whisper-cli, ffmpeg, and ffprobe are found on PATH unless overridden, and missing tools are named before any download starts.
|
|
||||||
- Optional "Focus on spoken dialogue" mode uses a separately downloadable Silero VAD model, keeping uncertain audible sections so dialogue under music is not dropped (songs may be transcribed too).
|
|
||||||
- Long passages are split near detected speech starts or quiet pauses, guided by an eligible embedded or external subtitle track already loaded in mpv when one is available, to reduce early subtitle timing. Each passage runs in a fresh Whisper process to avoid repeated-character output.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of 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 picks up non-conflicting keyboard bindings from mpv defaults, `input.conf`, and loaded scripts. SubMiner controls and explicitly disabled bindings take precedence; discovered bindings are session-only and not listed in the help menu.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Reduced installer and unpacked app size by dropping demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries from the package, and sharing one Japanese UI font across windows. Release builds now publish package size reports with comparisons against the previous release.
|
|
||||||
@@ -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. Selecting does not seek or require mining a card.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- The stats server now rejects requests from non-loopback hosts and browser origins and requires `application/json` for mutation bodies. The in-app stats overlay loads from the local server so it shares the same protection. Reverse-proxied or Tailscale Serve dashboards are unsupported; scripts that POST must set a JSON content type.
|
|
||||||
@@ -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 TMDB linking, provider reassignment, merge compatibility, and credential command caching.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Live-action dramas and movies in the stats Library get posters, synopses, and titles from TMDB. Release builds include a project key; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it and is required when running from source.
|
|
||||||
- Titles AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
|
|
||||||
- Provider reassignment keeps the previous link and artwork if the replacement download fails. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Documented the separate word-audio field mapping; existing animated images need regeneration to pick up the fix.
|
|
||||||
@@ -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,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- YouTube channels are now a separate Library media kind. Existing channel entries migrate automatically without changing viewing history or manual video assignments.
|
|
||||||
- Added All Titles, Anime, and YouTube Library filters. Channels stay out of AniList matching, season repair, and duplicate recommendations, and merges or video moves cannot mix an anime entry with a YouTube channel.
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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/`
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.20.0-beta.1",
|
"version": "0.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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||||
|
|
||||||
<!-- prerelease-version: 0.20.0-beta.1 -->
|
<!-- prerelease-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
|
||||||
|
|
||||||
- **Japanese Subtitle Generation**:
|
- **Japanese Subtitle Generation**:
|
||||||
@@ -17,6 +25,11 @@
|
|||||||
|
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -47,6 +60,7 @@
|
|||||||
- **Jellyfin**:
|
- **Jellyfin**:
|
||||||
- Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled.
|
- Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled.
|
||||||
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
|
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
|
||||||
|
- 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.
|
- 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".
|
- 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.
|
- 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.
|
||||||
@@ -55,6 +69,7 @@
|
|||||||
- 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.
|
- Word audio now reads from its own configured field (`ankiConnect.fields.wordAudio`) instead of the sentence-audio field, fixing animated word images that started moving immediately instead of on demand.
|
||||||
- Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
- **Settings**:
|
- **Settings**:
|
||||||
- AnkiConnect, Kiku, and Senren settings are now validated before use, with a warning and a safe default for anything invalid instead of a bad value reaching runtime.
|
- 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.
|
||||||
@@ -98,6 +113,9 @@
|
|||||||
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
|
- 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(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
|
||||||
- fix(jellyfin): support modern authentication by @ksyasuda in #264
|
- 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
|
## New Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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: ' 猫[ねこ]' } } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
audioBuffer ||
|
|
||||||
(syncAnimatedImageToWordAudio && generateImage) ||
|
|
||||||
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
noteInfo = noteInfoResult[0] ?? null;
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
errors.push(`note fields: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { formatSentenceFurigana } from '../../../anki-integration/sentence-furigana';
|
||||||
|
import { requestYomitanParseResults } from './yomitan-parser-runtime';
|
||||||
|
|
||||||
|
export async function generateSentenceFurigana(
|
||||||
|
text: string,
|
||||||
|
highlightedText: string | undefined,
|
||||||
|
deps: Parameters<typeof requestYomitanParseResults>[1],
|
||||||
|
logger: Parameters<typeof requestYomitanParseResults>[2],
|
||||||
|
): Promise<string | null> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const results = await Promise.race([
|
||||||
|
requestYomitanParseResults(text, deps, logger),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error('Sentence furigana generation timed out')),
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return formatSentenceFurigana(text, results, highlightedText);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.('Failed to generate sentence furigana:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: string | null | undefined;
|
openRuntimeOptions: string | null | undefined;
|
||||||
openJimaku: string | null | undefined;
|
openJimaku: string | null | undefined;
|
||||||
openTsukihime: string | null | undefined;
|
openTsukihime: string | null | undefined;
|
||||||
|
openSubtitleSelection: string | null | undefined;
|
||||||
openSubtitleGeneration: string | null | undefined;
|
openSubtitleGeneration: string | null | undefined;
|
||||||
openSessionHelp: string | null | undefined;
|
openSessionHelp: string | null | undefined;
|
||||||
openControllerSelect: string | null | undefined;
|
openControllerSelect: string | null | undefined;
|
||||||
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
|
|||||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||||
|
openSubtitleSelection:
|
||||||
|
config.subtitleSelection?.enabled === true
|
||||||
|
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
|
||||||
|
: null,
|
||||||
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
||||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||||
|
|||||||
+63
-30
@@ -15,6 +15,7 @@
|
|||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
import { generateSentenceFurigana } from './core/services/tokenizer/sentence-furigana';
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
@@ -75,7 +76,6 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { MecabTokenizer } from './mecab-tokenizer';
|
import { MecabTokenizer } from './mecab-tokenizer';
|
||||||
@@ -122,11 +122,6 @@ import {
|
|||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||||
import {
|
|
||||||
MPV_X11_BACKEND_ARGS,
|
|
||||||
applyX11EnvOverrides,
|
|
||||||
shouldForceX11WaylandSession,
|
|
||||||
} from './shared/mpv-x11-backend';
|
|
||||||
import { AnkiConnectClient } from './anki-connect';
|
import { AnkiConnectClient } from './anki-connect';
|
||||||
import {
|
import {
|
||||||
getStartupModeFlags,
|
getStartupModeFlags,
|
||||||
@@ -393,6 +388,7 @@ import {
|
|||||||
getConfiguredWindowsMpvPathStatus,
|
getConfiguredWindowsMpvPathStatus,
|
||||||
launchWindowsMpv,
|
launchWindowsMpv,
|
||||||
} from './main/runtime/windows-mpv-launch';
|
} from './main/runtime/windows-mpv-launch';
|
||||||
|
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
|
||||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||||
import {
|
import {
|
||||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||||
@@ -466,6 +462,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
|||||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
||||||
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
||||||
|
import {
|
||||||
|
createSubtitleSelectionRuntime,
|
||||||
|
openSubtitleSelectionModal,
|
||||||
|
registerSubtitleSelectionIpc,
|
||||||
|
} from './main/runtime/subtitle-selection';
|
||||||
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||||
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
||||||
@@ -677,22 +678,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
|
|||||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn a SubMiner-managed mpv (Jellyfin/YouTube) detached. On unsupported Wayland
|
|
||||||
* sessions it is pinned to XWayland — Wayland-hint env stripped and an X11 GPU context
|
|
||||||
* appended — so the XWayland overlay can stay above it, matching the `subminer` launcher.
|
|
||||||
*/
|
|
||||||
function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
|
|
||||||
if (!shouldForceX11WaylandSession(process.env)) {
|
|
||||||
return spawn('mpv', args, { detached: true, stdio: 'ignore' });
|
|
||||||
}
|
|
||||||
return spawn('mpv', [...args, ...MPV_X11_BACKEND_ARGS], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: applyX11EnvOverrides({ ...process.env }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
||||||
let jellyfinRemoteLastProgressAtMs = 0;
|
let jellyfinRemoteLastProgressAtMs = 0;
|
||||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||||
@@ -3179,18 +3164,20 @@ const {
|
|||||||
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
||||||
},
|
},
|
||||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||||
|
getMpvExecutablePath: () =>
|
||||||
|
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
|
||||||
getSocketPath: () => appState.mpvSocketPath,
|
getSocketPath: () => appState.mpvSocketPath,
|
||||||
getLaunchMode: () => configService.getConfig().mpv.launchMode,
|
getLaunchMode: () => configService.getConfig().mpv.launchMode,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
|
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
|
||||||
getInstalledPluginDetection: () =>
|
getInstalledPluginDetection: (mpvExecutablePath) =>
|
||||||
detectInstalledMpvPlugin({
|
detectInstalledMpvPlugin({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
homeDir: os.homedir(),
|
homeDir: os.homedir(),
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
appDataDir: app.getPath('appData'),
|
appDataDir: app.getPath('appData'),
|
||||||
mpvExecutablePath: configService.getConfig().mpv.executablePath,
|
mpvExecutablePath,
|
||||||
}),
|
}),
|
||||||
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
||||||
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
|
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
|
||||||
@@ -3198,7 +3185,7 @@ const {
|
|||||||
removeSocketPath: (socketPath) => {
|
removeSocketPath: (socketPath) => {
|
||||||
fs.rmSync(socketPath, { force: true });
|
fs.rmSync(socketPath, { force: true });
|
||||||
},
|
},
|
||||||
spawnMpv: (args) => spawnManagedMpvProcess(args),
|
spawnMpv: spawnMpvProcess,
|
||||||
logWarn: (message, error) => logger.warn(message, error),
|
logWarn: (message, error) => logger.warn(message, error),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
},
|
},
|
||||||
@@ -4583,6 +4570,7 @@ const {
|
|||||||
maybeStartOverlayLoadingOsd();
|
maybeStartOverlayLoadingOsd();
|
||||||
flushQueuedMpvOsdNotifications();
|
flushQueuedMpvOsdNotifications();
|
||||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||||
|
void refreshMpvSessionBindings();
|
||||||
if (appState.sessionBindingsInitialized) {
|
if (appState.sessionBindingsInitialized) {
|
||||||
sendMpvCommandRuntime(appState.mpvClient, [
|
sendMpvCommandRuntime(appState.mpvClient, [
|
||||||
'script-message',
|
'script-message',
|
||||||
@@ -5144,6 +5132,13 @@ function createMainWindow(): BrowserWindow {
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateMiningSentenceFurigana(
|
||||||
|
text: string,
|
||||||
|
highlightedText?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
return generateSentenceFurigana(text, highlightedText, getYomitanParserRuntimeDeps(), logger);
|
||||||
|
}
|
||||||
|
|
||||||
function initializeOverlayRuntime(): void {
|
function initializeOverlayRuntime(): void {
|
||||||
initializeOverlayRuntimeHandler();
|
initializeOverlayRuntimeHandler();
|
||||||
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
|
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
|
||||||
@@ -5153,6 +5148,7 @@ function initializeOverlayRuntime(): void {
|
|||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
||||||
|
appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5235,7 +5231,8 @@ const {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
|
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
|
||||||
|
createSessionBindingsRuntime({
|
||||||
configDir: CONFIG_DIR,
|
configDir: CONFIG_DIR,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
@@ -5248,6 +5245,17 @@ const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionB
|
|||||||
appState.sessionBindingsInitialized = initialized;
|
appState.sessionBindingsInitialized = initialized;
|
||||||
},
|
},
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
|
onBindingsChanged: (bindings) =>
|
||||||
|
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
|
||||||
|
onWarning: (warning) => {
|
||||||
|
if (warning.kind !== 'conflict') return;
|
||||||
|
overlayNotificationsRuntime.showOverlayNotification({
|
||||||
|
id: `session-binding-conflict:${warning.path}`,
|
||||||
|
title: 'Shortcut conflict',
|
||||||
|
body: warning.message,
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||||
@@ -5503,6 +5511,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
openJimaku: () => openJimakuOverlay(),
|
openJimaku: () => openJimakuOverlay(),
|
||||||
openTsukihime: () => openTsukihimeOverlay(),
|
openTsukihime: () => openTsukihimeOverlay(),
|
||||||
openSessionHelp: () => openSessionHelpOverlay(),
|
openSessionHelp: () => openSessionHelpOverlay(),
|
||||||
|
openSubtitleSelection: () => {
|
||||||
|
if (!configService.getConfig().subtitleSelection.enabled) return;
|
||||||
|
openOverlayHostedModalWithOsd(
|
||||||
|
openSubtitleSelectionModal,
|
||||||
|
'Subtitle selection overlay unavailable.',
|
||||||
|
'Failed to open subtitle selection overlay.',
|
||||||
|
);
|
||||||
|
},
|
||||||
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
||||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||||
openControllerSelect: () => openControllerSelectOverlay(),
|
openControllerSelect: () => openControllerSelectOverlay(),
|
||||||
@@ -5855,8 +5871,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getMpvInputBindings: () =>
|
getMpvInputBindings: async () => {
|
||||||
readMpvInputBindings({
|
await refreshMpvSessionBindings();
|
||||||
|
return readMpvInputBindings({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
||||||
platform:
|
platform:
|
||||||
@@ -5865,8 +5882,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
: process.platform === 'win32'
|
: process.platform === 'win32'
|
||||||
? 'win32'
|
? 'win32'
|
||||||
: 'linux',
|
: 'linux',
|
||||||
}),
|
});
|
||||||
getSessionBindings: () => appState.sessionBindings,
|
},
|
||||||
|
getSessionBindings: async () => {
|
||||||
|
await refreshMpvSessionBindings();
|
||||||
|
return appState.sessionBindings;
|
||||||
|
},
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||||
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||||
@@ -6003,6 +6024,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration?.setMediaTimingReviewCallback(
|
appState.ankiIntegration?.setMediaTimingReviewCallback(
|
||||||
mediaTimingReviewRuntime.requestReview,
|
mediaTimingReviewRuntime.requestReview,
|
||||||
);
|
);
|
||||||
|
appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana);
|
||||||
},
|
},
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
@@ -6649,6 +6671,17 @@ function setOverlayVisible(visible: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerIpcRuntimeHandlers();
|
registerIpcRuntimeHandlers();
|
||||||
|
registerSubtitleSelectionIpc({
|
||||||
|
ipc: ipcMain,
|
||||||
|
isAllowedSender: (sender) =>
|
||||||
|
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
|
||||||
|
(window) => window && !window.isDestroyed() && window.webContents === sender,
|
||||||
|
),
|
||||||
|
runtime: createSubtitleSelectionRuntime({
|
||||||
|
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
}),
|
||||||
|
});
|
||||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||||
getModelDirectory: () =>
|
getModelDirectory: () =>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
sleep: async () => {},
|
sleep: async () => {},
|
||||||
},
|
},
|
||||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
getSocketPath: () => '/tmp/test-mpv.sock',
|
getSocketPath: () => '/tmp/test-mpv.sock',
|
||||||
getLaunchMode: () => 'normal',
|
getLaunchMode: () => 'normal',
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
|
|||||||
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
|||||||
deps.setKeybindings(payload.keybindings);
|
deps.setKeybindings(payload.keybindings);
|
||||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||||
|
|
||||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
if (
|
||||||
|
diff.hotReloadFields.includes('shortcuts') ||
|
||||||
|
diff.hotReloadFields.includes('subtitleSelection')
|
||||||
|
) {
|
||||||
deps.refreshGlobalAndOverlayShortcuts();
|
deps.refreshGlobalAndOverlayShortcuts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
if (!connected || !mpvClient) {
|
if (!connected || !mpvClient) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
|
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
|
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
|
||||||
|
getMpvExecutablePath: () => '/usr/local/bin/mpv',
|
||||||
getSocketPath: () => '/tmp/mpv.sock',
|
getSocketPath: () => '/tmp/mpv.sock',
|
||||||
getLaunchMode: () => 'fullscreen',
|
getLaunchMode: () => 'fullscreen',
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
|||||||
getDefaultMpvLogPath: () => '/tmp/mpv.log',
|
getDefaultMpvLogPath: () => '/tmp/mpv.log',
|
||||||
defaultMpvArgs: ['--no-config'],
|
defaultMpvArgs: ['--no-config'],
|
||||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||||
spawnMpv: (args) => {
|
spawnMpv: (executablePath, args) => {
|
||||||
calls.push(`spawn:${args.join(' ')}`);
|
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
|
||||||
return proc;
|
return proc;
|
||||||
},
|
},
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
@@ -60,14 +61,20 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
|||||||
assert.equal(deps.platform, 'darwin');
|
assert.equal(deps.platform, 'darwin');
|
||||||
assert.equal(deps.execPath, '/tmp/subminer');
|
assert.equal(deps.execPath, '/tmp/subminer');
|
||||||
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||||
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
|
||||||
|
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
|
||||||
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
|
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
|
||||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||||
deps.removeSocketPath('/tmp/mpv.sock');
|
deps.removeSocketPath('/tmp/mpv.sock');
|
||||||
deps.spawnMpv(['--idle=yes']);
|
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
|
||||||
deps.logInfo('launched');
|
deps.logInfo('launched');
|
||||||
deps.logWarn('bad', null);
|
deps.logWarn('bad', null);
|
||||||
assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']);
|
assert.deepEqual(calls, [
|
||||||
|
'rm:/tmp/mpv.sock',
|
||||||
|
'spawn:/usr/local/bin/mpv --idle=yes',
|
||||||
|
'info:launched',
|
||||||
|
'warn:bad',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
|
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
|||||||
deps: LaunchMpvForJellyfinDeps,
|
deps: LaunchMpvForJellyfinDeps,
|
||||||
) {
|
) {
|
||||||
return (): LaunchMpvForJellyfinDeps => ({
|
return (): LaunchMpvForJellyfinDeps => ({
|
||||||
|
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
|
||||||
getSocketPath: () => deps.getSocketPath(),
|
getSocketPath: () => deps.getSocketPath(),
|
||||||
getLaunchMode: () => deps.getLaunchMode(),
|
getLaunchMode: () => deps.getLaunchMode(),
|
||||||
platform: deps.platform,
|
platform: deps.platform,
|
||||||
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
|||||||
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
|
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
|
||||||
defaultMpvArgs: deps.defaultMpvArgs,
|
defaultMpvArgs: deps.defaultMpvArgs,
|
||||||
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
|
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
|
||||||
spawnMpv: (args: string[]) => deps.spawnMpv(args),
|
spawnMpv: (executablePath, args) => deps.spawnMpv(executablePath, args),
|
||||||
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
|
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
|
||||||
|
import { resolveWindowsMpvPath } from './mpv-process';
|
||||||
import {
|
import {
|
||||||
createEnsureMpvConnectedForJellyfinPlaybackHandler,
|
createEnsureMpvConnectedForJellyfinPlaybackHandler,
|
||||||
createLaunchMpvIdleForJellyfinPlaybackHandler,
|
createLaunchMpvIdleForJellyfinPlaybackHandler,
|
||||||
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
|||||||
const spawnedArgs: string[][] = [];
|
const spawnedArgs: string[][] = [];
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
getSocketPath: () => '/tmp/subminer.sock',
|
getSocketPath: () => '/tmp/subminer.sock',
|
||||||
getLaunchMode: () => 'maximized',
|
getLaunchMode: () => 'maximized',
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
|||||||
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
|
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
|
||||||
defaultMpvArgs: ['--sid=auto'],
|
defaultMpvArgs: ['--sid=auto'],
|
||||||
removeSocketPath: () => {},
|
removeSocketPath: () => {},
|
||||||
spawnMpv: (args) => {
|
spawnMpv: (_executable, args) => {
|
||||||
spawnedArgs.push(args);
|
spawnedArgs.push(args);
|
||||||
return {
|
return {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
|||||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
|
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
|
||||||
const spawnedArgs: string[][] = [];
|
const spawnedArgs: string[][] = [];
|
||||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
getSocketPath: () => '/tmp/subminer.sock',
|
getSocketPath: () => '/tmp/subminer.sock',
|
||||||
getLaunchMode: () => 'normal',
|
getLaunchMode: () => 'normal',
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
|||||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||||
defaultMpvArgs: ['--sid=auto'],
|
defaultMpvArgs: ['--sid=auto'],
|
||||||
removeSocketPath: () => {},
|
removeSocketPath: () => {},
|
||||||
spawnMpv: (args) => {
|
spawnMpv: (_executable, args) => {
|
||||||
spawnedArgs.push(args);
|
spawnedArgs.push(args);
|
||||||
return {
|
return {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
|||||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
|
||||||
const spawnedArgs: string[][] = [];
|
const mpvPath = 'C:\\portable player\\mpv.exe';
|
||||||
|
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
|
||||||
|
for (const source of ['environment', 'PATH']) {
|
||||||
|
let resolutions = 0;
|
||||||
|
const spawned: Array<{ executable: string; args: string[] }> = [];
|
||||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||||
getSocketPath: () => '/tmp/subminer.sock',
|
getMpvExecutablePath: () => {
|
||||||
|
resolutions += 1;
|
||||||
|
return resolveWindowsMpvPath({
|
||||||
|
getEnv: () => (source === 'environment' ? mpvPath : undefined),
|
||||||
|
runWhere: () => ({ status: 0, stdout: mpvPath }),
|
||||||
|
fileExists: (candidate) => candidate === mpvPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
|
||||||
getLaunchMode: () => 'normal',
|
getLaunchMode: () => 'normal',
|
||||||
platform: 'linux',
|
platform: 'win32',
|
||||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
execPath: 'C:\\SubMiner\\SubMiner.exe',
|
||||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
|
||||||
getInstalledPluginDetection: () => ({
|
getInstalledPluginDetection: (mpvExecutablePath) =>
|
||||||
installed: true,
|
detectInstalledMpvPlugin({
|
||||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
platform: 'win32',
|
||||||
version: '0.1.0',
|
homeDir: 'C:\\Users\\test',
|
||||||
source: 'default-config',
|
mpvExecutablePath,
|
||||||
message: null,
|
existsSync: (candidate) => candidate === pluginPath,
|
||||||
}),
|
}),
|
||||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
getDefaultMpvLogPath: () => '',
|
||||||
defaultMpvArgs: ['--sid=auto'],
|
defaultMpvArgs: [],
|
||||||
removeSocketPath: () => {},
|
removeSocketPath: () => {},
|
||||||
spawnMpv: (args) => {
|
spawnMpv: (executable, args) => {
|
||||||
spawnedArgs.push(args);
|
spawned.push({ executable, args });
|
||||||
return {
|
return { on: () => {}, unref: () => {} };
|
||||||
on: () => {},
|
|
||||||
unref: () => {},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
launch();
|
launch();
|
||||||
|
assert.equal(resolutions, 1, source);
|
||||||
|
assert.equal(spawned.length, 1);
|
||||||
|
assert.equal(spawned[0]!.executable, mpvPath);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||||
|
|||||||
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LaunchMpvForJellyfinDeps = {
|
export type LaunchMpvForJellyfinDeps = {
|
||||||
|
getMpvExecutablePath: () => string;
|
||||||
getSocketPath: () => string;
|
getSocketPath: () => string;
|
||||||
getLaunchMode: () => MpvLaunchMode;
|
getLaunchMode: () => MpvLaunchMode;
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
execPath: string;
|
execPath: string;
|
||||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
|
||||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||||
getDefaultMpvLogPath: () => string;
|
getDefaultMpvLogPath: () => string;
|
||||||
defaultMpvArgs: readonly string[];
|
defaultMpvArgs: readonly string[];
|
||||||
removeSocketPath: (socketPath: string) => void;
|
removeSocketPath: (socketPath: string) => void;
|
||||||
spawnMpv: (args: string[]) => SpawnedProcessLike;
|
spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
|
||||||
logWarn: (message: string, error: unknown) => void;
|
logWarn: (message: string, error: unknown) => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
|
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
|
||||||
return (): void => {
|
return (): void => {
|
||||||
|
const executablePath = deps.getMpvExecutablePath();
|
||||||
const socketPath = deps.getSocketPath();
|
const socketPath = deps.getSocketPath();
|
||||||
if (deps.platform !== 'win32') {
|
if (deps.platform !== 'win32') {
|
||||||
try {
|
try {
|
||||||
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
|||||||
)
|
)
|
||||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||||
const installedPlugin = deps.getInstalledPluginDetection?.();
|
const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
|
||||||
const runtimePluginEntrypoint = installedPlugin?.installed
|
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||||
? ''
|
? ''
|
||||||
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||||
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
|||||||
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
|
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
|
||||||
`--input-ipc-server=${socketPath}`,
|
`--input-ipc-server=${socketPath}`,
|
||||||
];
|
];
|
||||||
const proc = deps.spawnMpv(mpvArgs);
|
const proc = deps.spawnMpv(executablePath, mpvArgs);
|
||||||
proc.on('error', (error) => {
|
proc.on('error', (error) => {
|
||||||
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
|
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
|
||||||
|
|
||||||
|
const testWindows = process.platform === 'win32' ? test : test.skip;
|
||||||
|
|
||||||
|
test('mpv process launcher forwards arguments and environment to the child', async () => {
|
||||||
|
const child = spawnMpvProcess(
|
||||||
|
process.execPath,
|
||||||
|
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
|
||||||
|
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const [code] = await once(child, 'exit');
|
||||||
|
assert.equal(code, 17);
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null) child.kill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWindows('managed Windows playback launches the configured executable', async () => {
|
||||||
|
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
|
||||||
|
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
|
||||||
|
try {
|
||||||
|
assert.equal(child.spawnfile, process.execPath);
|
||||||
|
const [code] = await once(child, 'exit');
|
||||||
|
assert.equal(code, 17);
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null) child.kill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWindows('managed Windows playback rejects an invalid configured executable', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => resolveMpvExecutablePath(`${process.execPath}/missing-mpv.exe`),
|
||||||
|
/Could not find mpv.exe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
MPV_X11_BACKEND_ARGS,
|
||||||
|
applyX11EnvOverrides,
|
||||||
|
shouldForceX11WaylandSession,
|
||||||
|
} from '../../shared/mpv-x11-backend';
|
||||||
|
|
||||||
|
export interface WindowsMpvPathDeps {
|
||||||
|
getEnv: (name: string) => string | undefined;
|
||||||
|
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||||
|
fileExists: (candidate: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
||||||
|
|
||||||
|
function fileExists(candidate: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(candidate).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredWindowsMpvPathStatus(
|
||||||
|
configuredMpvPath = '',
|
||||||
|
exists: (candidate: string) => boolean = fileExists,
|
||||||
|
): ConfiguredWindowsMpvPathStatus {
|
||||||
|
const configPath = configuredMpvPath.trim();
|
||||||
|
if (!configPath) {
|
||||||
|
return 'blank';
|
||||||
|
}
|
||||||
|
return exists(configPath) ? 'configured' : 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWindowsMpvPathDeps(
|
||||||
|
overrides: Partial<WindowsMpvPathDeps> = {},
|
||||||
|
): WindowsMpvPathDeps {
|
||||||
|
return {
|
||||||
|
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
|
||||||
|
fileExists: overrides.fileExists ?? fileExists,
|
||||||
|
runWhere:
|
||||||
|
overrides.runWhere ??
|
||||||
|
(() => {
|
||||||
|
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
stdout: result.stdout ?? '',
|
||||||
|
error: result.error ?? undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWindowsMpvPath(deps: WindowsMpvPathDeps, configuredMpvPath = ''): string {
|
||||||
|
const configPath = configuredMpvPath.trim();
|
||||||
|
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||||
|
if (configuredPathStatus === 'configured') {
|
||||||
|
return configPath;
|
||||||
|
}
|
||||||
|
if (configuredPathStatus === 'invalid') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
|
||||||
|
if (envPath && deps.fileExists(envPath)) {
|
||||||
|
return envPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereResult = deps.runWhere();
|
||||||
|
if (whereResult.status === 0) {
|
||||||
|
const firstPath = whereResult.stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||||
|
if (firstPath) {
|
||||||
|
return firstPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnMpvProcess(
|
||||||
|
executablePath: string,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): ReturnType<typeof spawn> {
|
||||||
|
const forceX11 = shouldForceX11WaylandSession(env);
|
||||||
|
return spawn(executablePath, forceX11 ? [...args, ...MPV_X11_BACKEND_ARGS] : args, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
env: forceX11 ? applyX11EnvOverrides({ ...env }) : env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
|
||||||
|
const executablePath =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
|
||||||
|
: 'mpv';
|
||||||
|
if (!executablePath) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return executablePath;
|
||||||
|
}
|
||||||
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
|
|||||||
fs.rmSync(root, { recursive: true, force: true });
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
|
||||||
|
const sequence: CompiledSessionBinding = {
|
||||||
|
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||||
|
originalKey: 'g-s',
|
||||||
|
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'openSubtitleSelection',
|
||||||
|
};
|
||||||
|
let nativeKeys: unknown = [];
|
||||||
|
let failDiscovery = false;
|
||||||
|
let published: CompiledSessionBinding[] = [];
|
||||||
|
const events: CompiledSessionBinding[][] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async () => {
|
||||||
|
if (failDiscovery) throw new Error('temporarily unavailable');
|
||||||
|
return nativeKeys;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const runtime = createSessionBindingsRuntime({
|
||||||
|
configDir: root,
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||||
|
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
|
||||||
|
getMpvClient: () => client,
|
||||||
|
setSessionBindings: (bindings) => {
|
||||||
|
published = bindings;
|
||||||
|
},
|
||||||
|
setSessionBindingsInitialized: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onBindingsChanged: (bindings) => events.push(bindings),
|
||||||
|
onWarning: (warning) => warnings.push(warning.message),
|
||||||
|
});
|
||||||
|
const readArtifact = () =>
|
||||||
|
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
|
||||||
|
try {
|
||||||
|
runtime.persistSessionBindings([sequence]);
|
||||||
|
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
|
||||||
|
await runtime.refreshMpvSessionBindings();
|
||||||
|
assert.deepEqual(published, []);
|
||||||
|
assert.deepEqual(events.at(-1), readArtifact().bindings);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0]!, /mpv input binding "g"/);
|
||||||
|
await runtime.refreshMpvSessionBindings();
|
||||||
|
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
failDiscovery = true;
|
||||||
|
await runtime.refreshMpvSessionBindings();
|
||||||
|
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
|
||||||
|
failDiscovery = false;
|
||||||
|
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
|
||||||
|
await runtime.refreshMpvSessionBindings();
|
||||||
|
assert.deepEqual(published, [sequence]);
|
||||||
|
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
|
||||||
|
assert.deepEqual(readArtifact().warnings, []);
|
||||||
|
client.connected = false;
|
||||||
|
await runtime.refreshMpvSessionBindings();
|
||||||
|
assert.deepEqual(published, [sequence]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,16 +6,26 @@ import {
|
|||||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||||
|
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
|
||||||
|
import {
|
||||||
|
reserveMpvSequencePrefixes,
|
||||||
|
resolveSessionSequenceConflicts,
|
||||||
|
} from '../../shared/session-key-sequences';
|
||||||
|
import type { SessionBindingWarning } from '../../types/session-bindings';
|
||||||
|
|
||||||
export interface SessionBindingsRuntimeDeps {
|
export interface SessionBindingsRuntimeDeps {
|
||||||
configDir: string;
|
configDir: string;
|
||||||
getKeybindings: () => Keybinding[];
|
getKeybindings: () => Keybinding[];
|
||||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
getMpvClient: () =>
|
||||||
|
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
|
||||||
|
| null;
|
||||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||||
logWarn: (message: string, details?: unknown) => void;
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
|
||||||
|
onWarning?: (warning: SessionBindingWarning) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||||
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||||
) => void;
|
) => void;
|
||||||
refreshCurrentSessionBindings: () => void;
|
refreshCurrentSessionBindings: () => void;
|
||||||
|
refreshMpvSessionBindings: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
|
let sourceBindings: CompiledSessionBinding[] = [];
|
||||||
|
let sourceWarnings: SessionBindingWarning[] = [];
|
||||||
|
let nativeSnapshot: {
|
||||||
|
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||||
|
keys: string[];
|
||||||
|
} | null = null;
|
||||||
|
let pending: {
|
||||||
|
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||||
|
promise: Promise<void>;
|
||||||
|
} | null = null;
|
||||||
|
let publishedSignature: string | null = null;
|
||||||
|
let reportedWarnings = new Set<string>();
|
||||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||||
if (process.platform === 'darwin') return 'darwin';
|
if (process.platform === 'darwin') return 'darwin';
|
||||||
if (process.platform === 'win32') return 'win32';
|
if (process.platform === 'win32') return 'win32';
|
||||||
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
bindings: CompiledSessionBinding[],
|
bindings: CompiledSessionBinding[],
|
||||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||||
): void {
|
): void {
|
||||||
|
sourceBindings = bindings;
|
||||||
|
sourceWarnings = warnings;
|
||||||
|
publishBindings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishBindings(): void {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
|
||||||
|
const result = resolveSessionSequenceConflicts(
|
||||||
|
sourceBindings,
|
||||||
|
reserveMpvSequencePrefixes(keys),
|
||||||
|
);
|
||||||
|
const warnings = [...sourceWarnings, ...result.warnings];
|
||||||
|
const signature = JSON.stringify([
|
||||||
|
result.bindings,
|
||||||
|
warnings,
|
||||||
|
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
|
]);
|
||||||
|
if (signature === publishedSignature) return;
|
||||||
const artifact = buildPluginSessionBindingsArtifact({
|
const artifact = buildPluginSessionBindingsArtifact({
|
||||||
bindings,
|
bindings: result.bindings,
|
||||||
warnings,
|
warnings,
|
||||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
});
|
});
|
||||||
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
deps.setSessionBindings(bindings);
|
publishedSignature = signature;
|
||||||
|
deps.setSessionBindings(result.bindings);
|
||||||
deps.setSessionBindingsInitialized(true);
|
deps.setSessionBindingsInitialized(true);
|
||||||
|
const nextWarnings = new Set(warnings.map((warning) => warning.message));
|
||||||
|
for (const warning of warnings) {
|
||||||
|
if (reportedWarnings.has(warning.message)) continue;
|
||||||
|
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||||
|
deps.onWarning?.(warning);
|
||||||
|
}
|
||||||
|
reportedWarnings = nextWarnings;
|
||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
if (mpvClient?.connected) {
|
if (mpvClient?.connected) {
|
||||||
try {
|
try {
|
||||||
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deps.onBindingsChanged?.(result.bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMpvSessionBindings(): Promise<void> {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (!client?.connected) {
|
||||||
|
nativeSnapshot = null;
|
||||||
|
publishBindings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending?.client === client) return pending.promise;
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const raw = await client.requestProperty('input-bindings');
|
||||||
|
if (client !== deps.getMpvClient() || !client.connected) return;
|
||||||
|
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
|
||||||
|
publishBindings();
|
||||||
|
} catch {
|
||||||
|
// Keep the last successful snapshot if discovery is temporarily unavailable.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const request = { client, promise };
|
||||||
|
pending = request;
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} finally {
|
||||||
|
if (pending === request) pending = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshCurrentSessionBindings(): void {
|
function refreshCurrentSessionBindings(): void {
|
||||||
const compiled = compileCurrentSessionBindings();
|
const compiled = compileCurrentSessionBindings();
|
||||||
for (const warning of compiled.warnings) {
|
|
||||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
|
||||||
}
|
|
||||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||||
|
void refreshMpvSessionBindings();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { generateSentenceFurigana } from '../../core/services/tokenizer/sentence-furigana';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import {
|
import {
|
||||||
@@ -166,6 +167,8 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
}),
|
}),
|
||||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||||
|
generateSentenceFurigana: (text, highlightedText) =>
|
||||||
|
generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger),
|
||||||
addYomitanNote: async (word: string) => {
|
addYomitanNote: async (word: string) => {
|
||||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user