mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7975b79bf2
|
||
|
|
9eecb7358b
|
||
|
|
9eb70f5ff2
|
||
|
|
773664db78
|
||
|
|
2d623838d5
|
||
|
|
1e5d7747b4 | ||
|
|
c14c690875
|
||
|
|
84f718043a | ||
|
|
99266294b8 | ||
|
|
c055359be1
|
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **Card Timing Review**:
|
||||
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
|
||||
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
|
||||
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
|
||||
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
|
||||
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Field Grouping**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
|
||||
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Subtitle & Mining Accuracy**:
|
||||
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
|
||||
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
|
||||
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
|
||||
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
|
||||
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
|
||||
- **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.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -86,14 +86,14 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
<td><b>Jellyfin</b></td>
|
||||
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Anime Browser</b></td>
|
||||
<td>Search anime sources you supply as <a href="https://github.com/aniyomiorg/aniyomi">Aniyomi</a> extension APKs and play an episode in mpv with the overlay attached (<code>subminer anime</code>); SubMiner ships no repositories and bundles no sources</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Jimaku</b></td>
|
||||
<td>Search and download Japanese subtitles</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Local Subtitle Generation</b></td>
|
||||
<td>Generate Japanese subtitles from local audio in a standalone modal (<code>Ctrl+Shift+G</code>), the sidebar button, or launcher, with progress and optional managed model downloads. Requires whisper.cpp and FFmpeg. Optional Silero speech detection prioritizes dialogue in separately timed passages. <a href="https://docs.subminer.moe/main/subtitle-generation">Setup guide</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>TsukiHime</b></td>
|
||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
||||
@@ -182,8 +182,6 @@ See the [full requirements list](https://docs.subminer.moe/installation#_1-insta
|
||||
|
||||
```bash
|
||||
paru -S subminer-bin
|
||||
# optional: the anime browser bridge, shared with Mangatan and updated by pacman
|
||||
paru -S mangatan-extension-server
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -262,28 +260,19 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
|
||||
|
||||
SubMiner builds on the work of these open-source projects:
|
||||
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
|
||||
| [Mangatan](https://github.com/1Selxo/Mangatan) | Reference client for the bridge protocol the anime browser speaks |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0](LICENSE)
|
||||
|
||||
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server),
|
||||
downloaded from its upstream releases at runtime rather than
|
||||
bundled or redistributed here; its bundles carry their own dependencies, including a JRE and
|
||||
GPL-3.0 NewPipe Extractor. SubMiner includes none of them and talks to the bridge
|
||||
over its own HTTP protocol. It ships no extensions or repositories by default.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Resolving "season N" against AniList no longer lands one season short for franchises whose broadcast seasons are listed as several entries. AniList records the back half of a split cour ("… Season 2 Part 2", "… Cour 2", "第2クール") as its own sequel, and the resolver counted each as a season — so Mushoku Tensei season 3 resolved to the season 2 entry and Re:ZERO season 4 to season 3, which then drove the character dictionary and AniList progress updates to the wrong series. A sequel carrying a part or cour marker that matches the season it continues is now followed without advancing the season count, in both the relation walk and the air-order fallback. Titles are compared ignoring punctuation, and the marker is looked for in every title and synonym rather than just the display title. A wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs removing (or overriding from the character dictionary picker) for the affected series.
|
||||
- A special or OVA sitting in a franchise's sequel chain is walked through without counting as a season. Dr. STONE links STONE WARS to New World through a one-episode special, which made New World resolve as season 4 and every later season shift with it.
|
||||
- An AniList rate limit or network failure while walking sequel relations is reported so the caller can retry, instead of falling through to the air-date fallback. That fallback is for a franchise with missing relation edges; running it after a failed lookup turned a transient 429 into a confidently wrong season.
|
||||
- The shared title parser recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named at the end of the title (`… Season 3`, `… 3rd Season`, `… S3`), which now fill the Season field instead of being searched for as part of the series name. The TsukiHime modal gained a Season field to match Jimaku, and seasons after the first are included in its search query so a later season's releases are actually found.
|
||||
@@ -1,34 +0,0 @@
|
||||
type: added
|
||||
area: anime
|
||||
|
||||
- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
|
||||
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
|
||||
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
|
||||
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
|
||||
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason. Repository requests time out instead of hanging, APK updates are staged before replacing the installed copy, and removing an extension refreshes the runtime even when saved credential cleanup fails while still reporting that failure.
|
||||
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
|
||||
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
|
||||
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
||||
- The Extensions tab opens with an Installed section listing every extension on disk with Remove, including ones added by hand or whose repository has since been removed. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
|
||||
- Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; **Set default** on an Installed row in the Extensions tab makes that source (or All sources) the one the browser opens on, and the current default carries a tag. SubMiner ships no extension repositories and performs no discovery.
|
||||
- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, waits for mpv's video window to appear, closes the in-player browser, hides the standalone browser window so it is not pulled in front of mpv when the modals close, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused.
|
||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
|
||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback, disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background, stalled response bodies retain the upstream inactivity timeout, and only origin-form requests can reach the configured local bridge.
|
||||
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
||||
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
||||
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
||||
- The bridge bundle is downloaded from the newest upstream release that ships a bundle for the platform (skipping releases older than the oldest server SubMiner is known to work with), the same way Mangatan fetches it, so all four published bundles (macOS arm64 and x64, Linux x64, Windows x64) work without a maintainer hashing each one first.
|
||||
- The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves.
|
||||
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
|
||||
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
|
||||
- Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream.
|
||||
- The episode list has a filter box: type an episode number (`12`), a range (`12-18`), or part of an episode name to narrow a long list, with a `6 of 25` counter while it is applied. Escape inside the box clears the filter instead of leaving the detail page.
|
||||
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
|
||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. The SubMiner logo identifies the Anime Browser in both the modal and standalone window.
|
||||
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
|
||||
- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
|
||||
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH.
|
||||
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. With the detector executable installed, retain brief utterances and extra audio around speech, split long passages near quiet pauses with overlapping context, and combine duplicate cues by greatest timing overlap. Keep original media timing and separate repeated dialogue without spanning omitted music breaks.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: macos
|
||||
|
||||
- `subminer anime` (and `--settings` / `--sync`) now bring their window to the front on macOS. `show()`/`focus()` only reorder windows inside the app that is already active, so the window opened behind the terminal that launched it; SubMiner now activates itself when opening one. The anime browser also restores its Dock icon before showing rather than after, because the overlay's fullscreen transform leaves the app as an accessory process that macOS refuses to bring forward at all.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: anki
|
||||
|
||||
- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries.
|
||||
- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subsync
|
||||
|
||||
- Subsync no longer fails with `Protocol "file:" not supported` on a subtitle that was dropped onto mpv. mpv reports such a track as a percent-encoded `file://` URL, which subsync read as a stream and tried to fetch over HTTP; the URL is now decoded back to its path, so both the retimed target and an alass reference work. A `file://` video path is treated as local too, which restores the video reference and ffsubsync for a dropped file.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: subsync
|
||||
|
||||
- Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. The track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
|
||||
- Alass now works on tracks that arrive as WebVTT, which is what Aniyomi extension streams serve. Alass picks its parser from the file extension and has no WebVTT support, so it treated a `.vtt` reference as a video file and failed with "no audio stream in file". Both the target and the reference are rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched.
|
||||
- Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
|
||||
- Subsync failures are written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Keep the subtitle sidebar near playback during gaps when the subtitle file has a cue starting at zero.
|
||||
+20
-22
@@ -6,6 +6,23 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
// Configure an existing GGML model path or explicitly download a SubMiner-managed model.
|
||||
// Hot-reload: settings apply to the next generation or model download.
|
||||
// ==========================================
|
||||
"subtitleGeneration": {
|
||||
"whisperPath": "", // Optional path override for whisper.cpp. Leave empty to find whisper-cli on PATH.
|
||||
"modelPath": "", // Path to an existing multilingual whisper.cpp GGML model. Leave empty to use a SubMiner-managed model. A configured path always takes precedence.
|
||||
"managedModel": "small", // Multilingual whisper.cpp model to use when modelPath is empty. Download it explicitly from the generation modal or launcher. Values: tiny | tiny-q5_1 | tiny-q8_0 | base | base-q5_1 | base-q8_0 | small | small-q5_1 | small-q8_0 | medium | medium-q5_0 | medium-q8_0 | large-v1 | large-v2 | large-v2-q5_0 | large-v2-q8_0 | large-v3 | large-v3-q5_0 | large-v3-turbo | large-v3-turbo-q5_0 | large-v3-turbo-q8_0
|
||||
"threads": 4, // Positive integer CPU thread count for whisper.cpp Japanese transcription.
|
||||
"ffmpegPath": "", // Optional FFmpeg path override for audio extraction. Leave empty to find ffmpeg on PATH.
|
||||
"ffprobePath": "", // Optional FFprobe path override for audio tracks and timing. Leave empty to find ffprobe on PATH.
|
||||
"vadModelPath": "", // Path to a whisper.cpp Silero VAD model. Enables dialogue-focused generation from separate speech passages. Leave empty to transcribe the full audio, including songs.
|
||||
"vadPath": "" // Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments on PATH.
|
||||
}, // Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
|
||||
// ==========================================
|
||||
// Visible Overlay Auto-Start
|
||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||
@@ -206,6 +223,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
@@ -341,12 +359,6 @@
|
||||
"__playlist-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Alt+KeyA", // Key setting.
|
||||
"command": [
|
||||
"__anime-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||
"command": [
|
||||
@@ -529,7 +541,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -575,6 +587,7 @@
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -622,21 +635,6 @@
|
||||
} // Lapis kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
// Anime Browser
|
||||
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
|
||||
// Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause. Values: true | false
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
|
||||
@@ -368,8 +368,8 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Anki', link: '/anki-integration' },
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Anime Browser', link: '/anime-browser' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'Subtitle Generation', link: '/subtitle-generation' },
|
||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# SubMiner Docs
|
||||
# SubMiner docs
|
||||
|
||||
In-repo VitePress documentation source for SubMiner.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# AniList Integration
|
||||
# AniList integration
|
||||
|
||||
SubMiner can sync your watch progress to [AniList](https://anilist.co) automatically. When you finish an episode, SubMiner detects the title and episode number from the filename, finds the matching AniList entry, and updates your progress via the GraphQL API. Failed updates are retried with exponential backoff in the background.
|
||||
SubMiner syncs your watch progress to [AniList](https://anilist.co). Finish an episode and it reads the title and episode number off the filename, finds the matching AniList entry, and updates your progress through the GraphQL API. A failed update retries in the background with exponential backoff.
|
||||
|
||||
AniList data also powers two additional features: [cover art](#cover-art) for the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
|
||||
The same AniList data feeds [cover art](#cover-art) in the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
|
||||
|
||||
[AniList](https://anilist.co) is a free website for tracking which anime you have watched. An **access token** is a private key SubMiner stores so it can update your list on your behalf - you approve it once during setup, and you never paste a password into SubMiner.
|
||||
[AniList](https://anilist.co) is a free anime tracking site. The **access token** is a private key SubMiner keeps so it can update your list for you. You approve it once during setup, and your AniList password never touches SubMiner.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -32,18 +32,18 @@ If the embedded auth UI fails to render, SubMiner opens the authorize URL in you
|
||||
You can also set `anilist.accessToken` directly in config to skip the setup flow entirely. When blank, SubMiner uses the locally stored encrypted token.
|
||||
:::
|
||||
|
||||
## How Tracking Works
|
||||
## How tracking works
|
||||
|
||||
SubMiner monitors playback and triggers an AniList progress update when an episode is considered "watched" -- at least 85% of the episode duration viewed and a minimum of 10 minutes watched.
|
||||
SubMiner watches playback and pushes an AniList progress update once an episode counts as watched. That means at least 85% of its duration, and at least 10 minutes either way.
|
||||
|
||||
The update flow:
|
||||
|
||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
|
||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
||||
1. **Title detection** - SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
2. **AniList search** - The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons - sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
|
||||
3. **Progress check** - SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
4. **Mutation** - A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
||||
|
||||
## Update Queue and Retry
|
||||
## Update queue and retry
|
||||
|
||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||
|
||||
@@ -58,7 +58,7 @@ After 8 failed attempts, the update is moved to a dead-letter queue and no longe
|
||||
|
||||
Use `--anilist-retry-queue` to manually process one ready item from the queue.
|
||||
|
||||
## Cover Art
|
||||
## Cover art
|
||||
|
||||
SubMiner fetches cover art from AniList for display in the stats dashboard. When a new video starts playing, the cover art fetcher:
|
||||
|
||||
@@ -71,11 +71,11 @@ A no-match result is cached for 5 minutes before SubMiner retries, preventing re
|
||||
|
||||
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
|
||||
|
||||
## Rate Limiting
|
||||
## Rate limiting
|
||||
|
||||
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
||||
|
||||
## Configuration Reference
|
||||
## Configuration reference
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -107,7 +107,7 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
||||
|
||||
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||
|
||||
## CLI Commands
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | ------------------------------------------------------------- |
|
||||
@@ -124,10 +124,10 @@ There is no `characterDictionary.enabled` key: character dictionary sync is enab
|
||||
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
|
||||
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
|
||||
- **Cover art missing:** Cover art is fetched on a best-effort basis using title matching. If the filename is hard to parse, the search may return no results. The fetcher retries after 5 minutes.
|
||||
- **Encryption unavailable on Linux:** If you see warnings about safeStorage, try `--password-store=basic_text` as a workaround, or ensure your desktop keyring (gnome-keyring, KWallet) is running.
|
||||
- **Encryption unavailable on Linux:** If you see warnings about safeStorage, try `--password-store=basic_text` as a workaround, or start your desktop keyring (gnome-keyring, KWallet).
|
||||
|
||||
## Related
|
||||
|
||||
- [Character Dictionary](/character-dictionary) -- AniList-powered character name dictionary for Yomitan
|
||||
- [Configuration Reference](/configuration) -- full config options
|
||||
- [Jellyfin Integration](/jellyfin-integration) -- media server integration
|
||||
- [Character Dictionary](/character-dictionary) - AniList-powered character name dictionary for Yomitan
|
||||
- [Configuration Reference](/configuration) - full config options
|
||||
- [Jellyfin Integration](/jellyfin-integration) - media server integration
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
# Anime Browser
|
||||
|
||||
Search anime sources, pick an episode, and play it in mpv with SubMiner's overlay
|
||||
and mining tools attached — the same way a local file or a Jellyfin stream works.
|
||||
|
||||
Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
|
||||
**Browse Anime** in the tray menu. The window stays open while you watch, so you
|
||||
can queue the next episode without reopening it.
|
||||
|
||||
During playback, `Ctrl+Alt+A` toggles the same browser as a modal inside the mpv
|
||||
player bounds. It uses a dedicated modal surface, so it stays above fullscreen
|
||||
playback and closes like the other in-player tools. Toggling it off keeps its
|
||||
current page and scroll position ready for the next toggle. The standalone
|
||||
window and the modal keep their own search, selected source, tab, and scroll
|
||||
state, so using one does not replace or cancel what you were doing in the other.
|
||||
|
||||
Both surfaces use the same playback queue, source configuration, and stats
|
||||
history. Queue changes and the currently playing episode appear in both
|
||||
immediately, and watched marks come from the same history that playback and the
|
||||
stats window update. Closing and reopening the modal therefore picks up progress
|
||||
made from either browser surface.
|
||||
|
||||
While the window is open, SubMiner shows a tray icon and — on macOS — appears
|
||||
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
|
||||
flip between the browser and mpv. SubMiner normally hides itself from the Dock
|
||||
because the subtitle overlay needs that to float above fullscreen video; it
|
||||
hides again when the window closes during playback.
|
||||
|
||||
Launching an episode starts a full SubMiner playback session, the same as
|
||||
playing a local file: the overlay and mining tools attach, and the tray icon
|
||||
stays available. In standalone `subminer anime` mode, closing the window while
|
||||
a video is playing leaves playback running — reopen the browser from the tray
|
||||
(**Browse Anime**). The app only exits with the window when nothing is playing.
|
||||
|
||||
## How it works
|
||||
|
||||
SubMiner does not implement any anime source itself. It runs **Aniyomi extension
|
||||
APKs** through a bundled JVM sidecar ([M-Extension-Server][mes]), asks the
|
||||
selected extension to resolve an episode, and hands the resulting URL to mpv.
|
||||
|
||||
```
|
||||
extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner overlay
|
||||
```
|
||||
|
||||
Because the extension resolves the stream, whichever sources you install decide
|
||||
what is available. SubMiner only hosts them.
|
||||
|
||||
## Installing extensions
|
||||
|
||||
**SubMiner ships no extension repositories and bundles no sources.** There is no
|
||||
default repository, no suggested list, and no discovery. Until you add one, the
|
||||
browser has nothing to search — that is deliberate, and it is what keeps SubMiner
|
||||
a neutral host rather than a distributor.
|
||||
|
||||
There are two ways to add extensions.
|
||||
|
||||
### From a repository
|
||||
|
||||
The window has three tabs — **Browse**, **Extensions**, and **Source settings** —
|
||||
and each one fills the window, so a long extension list is not squeezed in above
|
||||
the search results.
|
||||
|
||||
Open the **Extensions** tab, paste a repository index URL, and choose
|
||||
**Add repository**. The URL must be `https` and point at a `.json` index file —
|
||||
`index.min.json` is the common Aniyomi name, but repositories are free to publish
|
||||
under another one (for example `video.min.json`). Anything else is rejected
|
||||
immediately rather than failing later. Everything before the file name is treated
|
||||
as the repository root, so `.apk` and icon URLs are resolved relative to it.
|
||||
|
||||
Extensions your repositories offer but you do not have appear under
|
||||
**Available**, each with **Install**. Repositories are stored in config under
|
||||
`anime.repos`, so you can also manage them there and keep them in a dotfile.
|
||||
|
||||
Every row carries the extension's icon, as the repository publishes it, so a
|
||||
site is recognisable before you read the name. A repository row shows its host's
|
||||
favicon instead. Icons are the only part of a row that is fetched from the
|
||||
network, and a row whose icon is missing falls back to the first letter of its
|
||||
name rather than an empty box.
|
||||
|
||||
A repository index lists every language it knows about, which is far more than
|
||||
any one person reads, so the **Available** list has a language chip row above
|
||||
it. Pick one or more languages to narrow it, or **All** to clear the filter;
|
||||
picking a language replaces **All** rather than sitting beside it. Rows name the
|
||||
language in full ("Japanese" rather than `ja`), extensions whose sources span
|
||||
languages are grouped under **Multi-language**, and the Available heading counts
|
||||
how many of the offered extensions the filter leaves.
|
||||
|
||||
### Managing what is installed
|
||||
|
||||
The Extensions tab opens with an **Installed** section listing everything in the
|
||||
extensions directory, with the sources each one provides and a **Remove**
|
||||
button. It is built from the directory rather than from a repository, so an
|
||||
extension you dropped in by hand — or one whose repository you have since
|
||||
removed — is still listed and still removable. An installed extension borrows
|
||||
its icon from the catalogue, so one no repository carries shows its monogram.
|
||||
|
||||
SubMiner compares the APK's Android version code with the newest build in the
|
||||
configured repositories. **Update** is clickable only when the repository has a
|
||||
newer build. A current extension says **Up to date**. If an unusual APK has no
|
||||
readable version code, its disabled button says **Version unknown**. **Update
|
||||
all** installs every newer build in one pass and disables itself when there is
|
||||
nothing to update.
|
||||
|
||||
### From a file
|
||||
|
||||
Drop Aniyomi `.apk` files into the extensions directory, shown at the top of the
|
||||
Extensions tab. It defaults to `<userData>/anime-extensions` — on macOS,
|
||||
`~/Library/Application Support/SubMiner/anime-extensions` — and can be moved with
|
||||
`anime.extensionsDir`.
|
||||
|
||||
A single APK may provide several sources; each appears separately in the
|
||||
**Source** picker. Extensions that fail to load are listed in the Installed
|
||||
section with the reason, so a broken APK is visible rather than silently
|
||||
missing.
|
||||
|
||||
An extension that fails to load is skipped rather than blocking the others, so
|
||||
one bad APK will not hide the rest.
|
||||
|
||||
### Default source
|
||||
|
||||
The browser opens on the first installed source. To open on a different one,
|
||||
press **Set default** on its row in the Installed list; the row then carries a
|
||||
**default** tag, and only one source can carry it. The choice is written to
|
||||
`anime.defaultSource` and applies the next time a browser window or the
|
||||
in-player modal opens. With more than one source installed, an **All sources**
|
||||
row at the top of the list can be the default too. A default that is no longer
|
||||
installed falls back to the first source.
|
||||
|
||||
## Searching every source at once
|
||||
|
||||
With more than one source installed, the **Source** picker gains an
|
||||
**All sources** entry. Searching with it selected runs the query against every
|
||||
installed source at once, and each source's results appear the moment that
|
||||
source answers — a fast source is on screen while a slow one is still
|
||||
resolving. The status bar counts sources as they finish
|
||||
(`Searching… 3/5 sources · 42 results`).
|
||||
|
||||
Each cover is labelled with the source it came from, and opening one always
|
||||
queries that source, whatever the picker says afterwards.
|
||||
|
||||
A source that errors is named in the status bar and the rest still show their
|
||||
results; one extension that needs a login cannot blank the grid. If every
|
||||
source fails, the first error is shown in full.
|
||||
|
||||
Typing a new search while one is still running simply starts over: results
|
||||
from the superseded search are discarded, even if its sources answer late.
|
||||
|
||||
When a source reports another page, **Load more** appears below the covers.
|
||||
It appends the next page without duplicating entries that already arrived in
|
||||
the live result stream. A failed next-page request remains available to retry.
|
||||
|
||||
Source settings belong to a single extension, so the **Source settings** tab
|
||||
asks you to pick one while **All sources** is selected.
|
||||
|
||||
## Finding an episode, and what you have watched
|
||||
|
||||
An episode list can run to hundreds of entries, so the episode header carries a
|
||||
filter box:
|
||||
|
||||
- A number, `12`, keeps that episode. Sources that report no numbers at all
|
||||
are still searched by name, so `12` also matches `Episode 12` in a title.
|
||||
- A range, `12-18`, keeps the episodes between the two, in either order
|
||||
(`18-12` reads the same). Episodes the source gave no number are left out of
|
||||
a range.
|
||||
- Anything else is a case-insensitive substring of the episode name, so `beach`
|
||||
finds `OVA: Beach Special`.
|
||||
|
||||
The counter next to **Episodes** reads `6 of 25` while a filter is applied.
|
||||
Pressing Escape in the filter box clears it; pressing it anywhere else goes back
|
||||
to the results grid.
|
||||
|
||||
Episodes you have already watched are dimmed and marked `✓ watched`, with a
|
||||
count in the header. This is not a separate list the browser keeps: it reads the
|
||||
same stats history the rest of SubMiner writes to, where an episode is marked
|
||||
watched once a session runs past the completion threshold. Streams are recorded
|
||||
under a stable per-episode identity, so the mark survives the stream URL
|
||||
changing between playbacks, and it is the same mark the stats window and
|
||||
`--mark-watched` use.
|
||||
|
||||
Because playback marks an episode partway through the session, the marks
|
||||
refresh when the browser window comes back to the front: finish an episode in
|
||||
mpv, switch back, and it is marked. With
|
||||
[immersion tracking](configuration.md) disabled there is no history to read, so
|
||||
no episode is marked.
|
||||
|
||||
### Marking by hand, and catching up
|
||||
|
||||
Right-click an episode for:
|
||||
|
||||
- **Mark watched** / **Mark unwatched**: the single episode, whichever way it
|
||||
is not already.
|
||||
- **Mark this and N below watched** / **... unwatched**: that episode and every
|
||||
episode listed below it. Sources list newest first, so "below" is the back
|
||||
catalogue: right-click the last episode you saw and mark everything down to
|
||||
the start, which is how you catch up a series you watched somewhere else.
|
||||
|
||||
A filter narrows what you are looking at, not what you mark: a span always
|
||||
covers the full episode list, and the status bar says how many episodes it
|
||||
touched. The oldest episode has nothing below it, so it only offers the single
|
||||
entry. Escape closes the menu.
|
||||
|
||||
Marking an episode you have never played creates its stats row so the mark has
|
||||
somewhere to live. That row carries the same series, season and episode fields
|
||||
playback would have recorded, and both stats library views join the lifetime
|
||||
tables, so a manually marked episode does not appear there as watch time you
|
||||
never spent. Clearing a mark never creates anything.
|
||||
|
||||
Marks are written to the stats history, so with immersion tracking disabled
|
||||
there is nowhere to write them and the status bar says so.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Purpose |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. |
|
||||
| `anime.repos` | Repository index URLs. Empty by default. |
|
||||
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
||||
| `anime.defaultSource` | Source the browser opens on: a source id or `all`. Empty uses the first. |
|
||||
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
||||
|
||||
Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode
|
||||
to Jimaku. SubMiner pauses playback on its first frame, waits for mpv's video
|
||||
window to appear, closes the in-player browser if it is open, hides the
|
||||
standalone browser window, and opens Jimaku with the source title, season, and
|
||||
episode already filled in. Playback resumes after the selected subtitle loads.
|
||||
Closing Jimaku also releases the automatic pause, while playback that was
|
||||
already paused stays paused. A stream that never shows a window resumes without
|
||||
opening Jimaku. Reopen the browser window with `subminer anime`, the tray
|
||||
entry, or the shortcut to keep browsing.
|
||||
|
||||
## Source settings
|
||||
|
||||
Most extensions need configuration before they return anything — a server
|
||||
address and credentials, a preferred quality, a language filter. Open the
|
||||
**Source settings** tab to edit them. Changes save as you make them and
|
||||
persist across restarts in `<userData>/anime-source-preferences.json`.
|
||||
|
||||
Each save is handed back to the extension, so it can react: the Jellyfin source
|
||||
logs in when the address and password land, then fills in its media-library
|
||||
picker. Password-like fields are masked. Because that file can hold
|
||||
credentials, it is written with owner-only permissions. Values are scoped to
|
||||
the exact extension package and source, so two extensions that reuse the same
|
||||
internal source ID cannot read each other's settings. Preferences saved by an
|
||||
older build without package ownership are discarded; re-enter those source
|
||||
settings once after upgrading.
|
||||
|
||||
## The bridge
|
||||
|
||||
SubMiner looks for the server in three places, in order, and runs the first
|
||||
one it finds:
|
||||
|
||||
1. `anime.bridgeDir`, if set. It must hold a Java runtime and the
|
||||
`MExtensionServer-*.jar`, laid out as upstream ships them. A directory that
|
||||
holds neither is an error rather than a silent fallback.
|
||||
2. A package-manager install. On Arch that is the AUR
|
||||
[`mangatan-extension-server`](https://aur.archlinux.org/packages/mangatan-extension-server)
|
||||
package, which Mangatan also uses, at `/usr/share/mangatan/extension_server`.
|
||||
Installing it means no download, and pacman keeps the bridge current. The
|
||||
`subminer-bin` package lists it as an optional dependency.
|
||||
3. SubMiner's own copy in `<userData>/anime-bridge`. The first launch downloads
|
||||
the newest upstream release that ships a bundle for your platform (~130 MB,
|
||||
containing the server and a matching Java runtime, so no system JDK is
|
||||
required), the same way Mangatan does. Releases older than the oldest server
|
||||
SubMiner is known to work with are skipped. Progress appears in the banner
|
||||
at the top of the window.
|
||||
|
||||
The **Bridge** note at the bottom of the Extensions tab says which one is in
|
||||
use, its version, and who updates it.
|
||||
|
||||
### Updating the bridge
|
||||
|
||||
Only the copy SubMiner downloaded is ever updated by SubMiner. A package-manager
|
||||
install or an `anime.bridgeDir` belongs to whoever put it there.
|
||||
|
||||
SubMiner records the release it installed and, once the bridge is running,
|
||||
asks GitHub for the newest one. When upstream has published a newer release,
|
||||
the banner reads "Extension bridge v… is installed; v… is available" with an
|
||||
**Update to v…** button. Clicking it downloads the new release beside the
|
||||
running bridge, so a failed download changes nothing, then stops the bridge,
|
||||
swaps the directories, and starts it again. The restart takes a few seconds
|
||||
and kills the stream of an episode that is playing, the same as when the
|
||||
bridge exits for any other reason; the queue and browser state survive. The
|
||||
check is one unauthenticated GitHub API call per bridge start; if it fails
|
||||
(offline, rate limited) it is logged and no update is offered until the next
|
||||
start.
|
||||
|
||||
Upstream publishes no checksums, so the download is trusted the way Mangatan
|
||||
and the AUR package trust it: TLS to GitHub and the maintainer's account. That
|
||||
is the same trust running the server implies in the first place.
|
||||
|
||||
The bridge stays running while the window is open. Resolved video URLs point at
|
||||
its own loopback proxy so the extension's cookies and headers apply, which means
|
||||
those URLs stop working once it exits — the window keeps it alive for the whole
|
||||
session.
|
||||
|
||||
If the bridge dies anyway (killed by hand, crashed, or stopped mid-operation),
|
||||
the exit is detected and named in the status bar, and the next request starts a
|
||||
new one. Playback already in flight still ends when its stream URL dies, but the
|
||||
browser recovers without an app restart.
|
||||
|
||||
Two known limits:
|
||||
|
||||
- There is no Android WebView, so extensions that need one (typically for
|
||||
Cloudflare challenges) will fail with an error from the source.
|
||||
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64).
|
||||
Other platforms are unsupported outright.
|
||||
|
||||
## Playback
|
||||
|
||||
Selecting an episode resolves the best available stream, applies the source's
|
||||
required headers as mpv `http-header-fields`, and loads it. The headers are
|
||||
readable back off mpv, so Anki card audio and screenshots fetch correctly too.
|
||||
|
||||
HLS streams are routed through a small local proxy before mpv sees them. Some
|
||||
hosts disguise their video segments by prepending a fake image header (a real
|
||||
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
|
||||
probes the segment as a picture and playback dies with "no audio or video data
|
||||
played". The proxy scans each segment for the first genuine MPEG-TS packet run
|
||||
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
|
||||
subtitles, encryption keys) pass through untouched, and direct-file streams
|
||||
skip the proxy entirely. Segment URLs disguised behind fake extensions
|
||||
(`.image`, `.jpg`, `.css`, and friends) are exposed locally with a `.ts`
|
||||
suffix so current ffmpeg releases accept them when Anki extracts audio,
|
||||
screenshots, or animated images from the playing stream.
|
||||
|
||||
"Playing" in the status bar means playing: after handing mpv the stream,
|
||||
SubMiner waits until mpv actually configures a video output before reporting
|
||||
success. If mpv gives up instead — a dead host, an undecodable stream — the
|
||||
browser shows mpv's error rather than pretending playback started (a failed
|
||||
load leaves no mpv window, because the player idles windowless).
|
||||
|
||||
Choosing **Queue** resolves the episode and appends the playable stream to mpv's
|
||||
own playlist immediately, while its subtitle tracks cache in the background.
|
||||
That makes mpv's next command available at once and lets the next episode begin
|
||||
automatically when the current one ends, without waiting for another source
|
||||
request. Resolved stream URLs can be short-lived, so a very long queue can still
|
||||
outlive what its source issued; dequeue and queue that episode again to refresh
|
||||
it.
|
||||
|
||||
### Japanese audio, and switching tracks
|
||||
|
||||
Sources often return a dub and the original audio as two separate entries — or
|
||||
as two audio tracks of one stream — and the dub is frequently listed first.
|
||||
SubMiner always aims at the Japanese audio:
|
||||
|
||||
- Entries labelled as a dub are skipped as long as another entry exists. This
|
||||
outranks `anime.preferredQuality`: a 1080p dub is the wrong file, not a better
|
||||
one. If every entry is a dub, it still plays.
|
||||
- mpv's `alang` is set to `ja,jpn,jp,japanese` before the file loads, so a
|
||||
stream carrying several audio tracks starts on the Japanese one. With no
|
||||
Japanese track, mpv falls back to the first one as usual.
|
||||
- Any audio or subtitle tracks the extension supplies separately are added to
|
||||
mpv with `audio-add` / `sub-add`, tagged with their language, and the
|
||||
Japanese one is selected.
|
||||
|
||||
The primary subtitle slot is reserved for Japanese — it is what the overlay
|
||||
mines. A source that only carries, say, English subtitles does not get them
|
||||
promoted to primary; instead the track is added with a normalized language tag
|
||||
(`English` → `en`), and the regular [dual-subtitle settings](configuration.md)
|
||||
apply: with `secondarySub.autoLoadSecondarySub` enabled and the language listed
|
||||
in `secondarySub.secondarySubLanguages`, it is picked up as the secondary
|
||||
subtitle, exactly as it would be for a local file.
|
||||
|
||||
Every track is added, including the ones that are not selected, so all of them
|
||||
appear in mpv's track menu and can be switched by hand while watching
|
||||
(`#` cycles audio, `j` cycles subtitles by default).
|
||||
|
||||
The extension hands over subtitle tracks as URLs, but SubMiner downloads each
|
||||
one to a temporary directory and gives mpv the local file. mpv is happy either
|
||||
way; [Subsync](/troubleshooting#subtitle-sync-subsync) is not, because alass
|
||||
needs a file on disk to use as the timing reference. A track that fails to
|
||||
download falls back to its URL so the episode still plays, the format is
|
||||
detected from the file's own content rather than its URL, and the directory is
|
||||
removed when the next episode starts or the app exits.
|
||||
|
||||
### Series, season, and episode
|
||||
|
||||
The episode's identity travels with it instead of being guessed back out of the
|
||||
stream URL, which carries nothing but a proxy path and a file extension. The
|
||||
title and episode label from the source's own listing are split into series,
|
||||
season, and episode number once, at launch, and everything downstream reads
|
||||
those fields:
|
||||
|
||||
- mpv's title reads `Series S03E04 - Episode Name`.
|
||||
- Stats group by series, and rewatching an episode reuses its entry instead of
|
||||
creating a new one.
|
||||
- The [Jimaku](/jimaku-integration) and [TsukiHime](/tsukihime-integration)
|
||||
modals open with Title, Season, and Episode already filled in, so a subtitle
|
||||
search is one keypress rather than a retype.
|
||||
- [AniList](/anilist-integration) progress updates use those fields directly.
|
||||
|
||||
[mes]: https://github.com/1Selxo/M-Extension-Server
|
||||
@@ -1,8 +1,8 @@
|
||||
# AniSkip Integration
|
||||
# AniSkip integration
|
||||
|
||||
SubMiner integrates with [AniSkip](https://aniskip.com) to automatically detect anime intro intervals and let you skip them with a single key press.
|
||||
SubMiner looks up anime intro timings from [AniSkip](https://aniskip.com) so you can jump past the OP with one key.
|
||||
|
||||
Intro detection runs in the SubMiner app over the mpv IPC socket. It is available whenever the overlay is connected to mpv - not just at launch - and covers every local file loaded during an mpv session, including playlist advances.
|
||||
Intro detection runs in the SubMiner app over the mpv IPC socket. It works whenever the overlay is connected to mpv, not only at launch, and covers every local file loaded during the session including playlist advances.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -25,9 +25,9 @@ For best title and episode detection, install [`guessit`](https://github.com/gue
|
||||
python3 -m pip install --user guessit
|
||||
```
|
||||
|
||||
Without `guessit`, SubMiner falls back to an internal filename parser which handles most common naming conventions but may miss unusual formats.
|
||||
Without `guessit`, SubMiner falls back to its own filename parser. That handles the usual release naming, but unusual formats slip past it.
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
On each local file load:
|
||||
|
||||
@@ -39,15 +39,15 @@ On each local file load:
|
||||
|
||||
When a custom key (other than `TAB` or `y-k`) is configured, the legacy `y-k` chord is also bound as a fallback skip trigger.
|
||||
|
||||
Results are cached per file for the app session; only definitive "no intro found" results are cached, so transient lookup failures are retried on the next file load. Reload detection is also handled: if mpv reloads the same file, SubMiner re-applies the chapter markers without a new API lookup.
|
||||
Results are cached per file for the app session. Only a definitive "no intro found" is cached, so a failed lookup gets retried on the next load rather than sticking. If mpv reloads the same file, SubMiner re-applies the chapter markers without hitting the API again.
|
||||
|
||||
## Triggering from mpv
|
||||
|
||||
You can trigger AniSkip actions from mpv script-messages:
|
||||
AniSkip actions are also reachable from mpv script-messages:
|
||||
|
||||
| Command | Effect |
|
||||
| ------- | ------ |
|
||||
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
|
||||
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
|
||||
|
||||
These are handled by the SubMiner app over the IPC socket.
|
||||
The SubMiner app handles both over the IPC socket.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Anki Integration
|
||||
# Anki integration
|
||||
|
||||
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||
@@ -19,28 +19,27 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
||||
|
||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
||||
|
||||
## Auto-Enrichment Transport
|
||||
## Auto-enrichment transport
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
|
||||
|
||||
**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||
**Proxy mode** (default) - SubMiner runs a small local server between Yomitan and Anki. Yomitan sends the new card to SubMiner, SubMiner fills in the media fields, and the finished card goes on to Anki. There is no polling delay.
|
||||
|
||||
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
|
||||
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether new cards showed up, then fills them in. Less to configure, at the cost of roughly a 3 second delay.
|
||||
|
||||
Use proxy mode if you want immediate enrichment. Use polling mode if your Yomitan instance is external (browser-based) or you prefer minimal configuration.
|
||||
Use proxy mode unless your Yomitan runs in a browser rather than the bundled instance, in which case polling is the simpler path.
|
||||
|
||||
In both modes, the enrichment workflow is the same:
|
||||
|
||||
1. Checks if a duplicate expression already exists (for field grouping).
|
||||
2. Updates the sentence field with the current subtitle.
|
||||
3. Generates and uploads audio and image media.
|
||||
4. Fills the translation field from the secondary subtitle or AI.
|
||||
5. Writes metadata to the miscInfo field.
|
||||
4. Writes metadata to the miscInfo field.
|
||||
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks (`added:1`). In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect; stats-dashboard mining also falls back to Yomitan's mining deck when `ankiConnect.deck` is empty.
|
||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||
|
||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||
### Proxy mode setup (Yomitan / texthooker)
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
@@ -83,7 +82,7 @@ In Yomitan, go to Settings → Profile and:
|
||||
|
||||
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets the active profile when its server is blank or still default.
|
||||
|
||||
### Proxy Troubleshooting (quick checks)
|
||||
### Proxy troubleshooting (quick checks)
|
||||
|
||||
If auto-enrichment appears to do nothing:
|
||||
|
||||
@@ -107,7 +106,7 @@ curl -sS http://127.0.0.1:8766 \
|
||||
- Launcher log: `launcher-YYYY-MM-DD.log`
|
||||
- mpv log: `mpv-YYYY-MM-DD.log`
|
||||
|
||||
4. Ensure config JSONC is valid and logging shape is correct:
|
||||
4. Check that the config JSONC parses and the logging shape is right:
|
||||
|
||||
```jsonc
|
||||
"logging": {
|
||||
@@ -117,30 +116,31 @@ curl -sS http://127.0.0.1:8766 \
|
||||
|
||||
`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
|
||||
|
||||
## Field Mapping
|
||||
## Field mapping
|
||||
|
||||
SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`:
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"fields": {
|
||||
"word": "Expression", // mined word / expression text
|
||||
"audio": "ExpressionAudio", // audio clip from the video
|
||||
"image": "Picture", // screenshot or animated clip
|
||||
"sentence": "Sentence", // subtitle text
|
||||
"miscInfo": "MiscInfo", // metadata (filename, timestamp)
|
||||
"translation": "SelectionText" // secondary sub or AI translation
|
||||
"word": "Expression", // mined word / expression text
|
||||
"audio": "SentenceAudio", // sentence audio clip cut from the video
|
||||
"image": "Picture", // screenshot or animated clip
|
||||
"sentence": "Sentence", // subtitle text
|
||||
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields.audio` receives the **sentence** audio SubMiner cuts from the video, not word audio. Yomitan writes its own dictionary audio when you mine, so point this at a separate field such as `SentenceAudio` to keep the two apart. The built-in default is still `ExpressionAudio`, which collides with Yomitan on note types that use that field for word audio.
|
||||
|
||||
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
|
||||
### Minimal Config
|
||||
### Minimal config
|
||||
|
||||
If you only want sentence and audio on your cards:
|
||||
|
||||
@@ -149,14 +149,16 @@ If you only want sentence and audio on your cards:
|
||||
"enabled": true,
|
||||
"fields": {
|
||||
"sentence": "Sentence",
|
||||
"audio": "ExpressionAudio"
|
||||
"audio": "SentenceAudio"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Media Generation
|
||||
## Media generation
|
||||
|
||||
SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg must be installed and on `PATH`.
|
||||
SubMiner shells out to FFmpeg for audio clips and screenshots, so FFmpeg has to be installed and on `PATH`.
|
||||
|
||||
For remote streams such as Jellyfin playback, SubMiner downloads the clip's time window once into a temporary Matroska file (a stream copy, no re-encoding) and reads the timing review waveform, audio preview, audio, and image from that file instead of fetching the stream again for each step. The window covers the clip plus padding, plus the visible timeline in timing review, and grows when you reveal more of the timeline. It is deleted when a different window replaces it, after ten minutes without use, or when SubMiner exits. If the download fails, media generation reads the remote stream directly as before.
|
||||
|
||||
### Audio
|
||||
|
||||
@@ -168,6 +170,7 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||
"reviewTiming": false, // review and adjust timing before media generation
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
@@ -180,7 +183,27 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
### Screenshots (Static)
|
||||
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
|
||||
|
||||
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
|
||||
|
||||
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
|
||||
|
||||
**Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels.
|
||||
|
||||
**The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work.
|
||||
|
||||
The range you confirm is used exactly as-is; SubMiner does not add audio padding a second time. Static screenshots take its midpoint, and animated AVIF clips cover the whole range.
|
||||
|
||||
**Pulling in adjacent lines.** Press `P` or `N`, or use the Prev and Next steppers above the sentence preview, to add the previous or next subtitle line. Repeat for as many lines as exist. Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined one highlighted, so you always see the sentence field before confirming. The clip bounds and the waveform rails follow the outermost added line, keeping the review's audio padding.
|
||||
|
||||
Confirming writes the combined lines to the sentence field. Reset drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise you only get lines that already played. A clip capped by `media.maxMediaDuration` still keeps the full combined sentence even when the audio cannot stretch to cover every added line.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
### Screenshots (static)
|
||||
|
||||
A single frame is captured at the current playback position.
|
||||
|
||||
@@ -197,9 +220,9 @@ A single frame is captured at the current playback position.
|
||||
}
|
||||
```
|
||||
|
||||
### Animated Clips (AVIF)
|
||||
### Animated clips (AVIF)
|
||||
|
||||
Instead of a static screenshot, SubMiner can generate an animated AVIF covering the subtitle duration.
|
||||
SubMiner can produce an animated AVIF spanning the subtitle duration instead of a still frame.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
@@ -216,7 +239,7 @@ Instead of a static screenshot, SubMiner can generate an animated AVIF covering
|
||||
|
||||
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio.
|
||||
|
||||
### Behavior Options
|
||||
### Behavior options
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
@@ -237,40 +260,7 @@ When media is available, mined-card overlay and system notifications include the
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
|
||||
|
||||
## AI Translation
|
||||
|
||||
SubMiner can auto-translate the mined sentence and fill the translation field.
|
||||
Secondary subtitle text still wins when present. AI translation is only attempted when `ankiConnect.ai.enabled` is `true` and no secondary subtitle exists.
|
||||
|
||||
```jsonc
|
||||
"ai": {
|
||||
"enabled": true,
|
||||
"apiKey": "sk-...",
|
||||
"apiKeyCommand": "",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"requestTimeoutMs": 15000
|
||||
},
|
||||
"ankiConnect": {
|
||||
"ai": {
|
||||
"enabled": true,
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"systemPrompt": "Translate mined sentence text only."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ankiConnect.ai` controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
Provider credentials and request transport settings live in top-level `ai`.
|
||||
|
||||
Translation priority:
|
||||
|
||||
1. If a secondary subtitle is available, use it as the translation.
|
||||
2. If `ankiConnect.ai.enabled` is `true` and top-level `ai.enabled` is `true`, call the shared AI provider.
|
||||
3. If AI translation fails and no secondary subtitle exists, fall back to the original sentence text.
|
||||
|
||||
The built-in translation request asks for English output by default. Customize that behavior through `ankiConnect.ai.systemPrompt`.
|
||||
|
||||
## Sentence Cards (Lapis)
|
||||
## Sentence cards (Lapis)
|
||||
|
||||
SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types.
|
||||
|
||||
@@ -293,7 +283,7 @@ The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatib
|
||||
|
||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||
|
||||
## Word Card Type (Kiku/Lapis)
|
||||
## Word card type (Kiku/Lapis)
|
||||
|
||||
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||
|
||||
@@ -308,7 +298,7 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
|
||||
|
||||
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
||||
|
||||
## Field Grouping (Kiku/Senren)
|
||||
## Field grouping (Kiku/Senren)
|
||||
|
||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
|
||||
|
||||
@@ -342,20 +332,20 @@ For Senren note types, enable `isSenren` instead. Kiku and Senren write incompat
|
||||
|
||||
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
||||
|
||||
### What Gets Merged
|
||||
### What gets merged
|
||||
|
||||
| Field | Merge behavior |
|
||||
| -------- | --------------------------------------------- |
|
||||
| Sentence | Both cards' sentences kept as grouped entries |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
| Field | Merge behavior |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| Sentence | Both cards' sentences kept as grouped entries |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
| MiscInfo | Both cards' source info kept as grouped entries |
|
||||
|
||||
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
||||
|
||||
The merge markup depends on the note type. Kiku entries are wrapped in `<span data-group-id="...">` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries use `group` spans when ordinal order is sufficient and numbered `groupN` spans when they need an absolute scene target. Audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and source `groupN` spans are rebased after the kept note's existing audio scenes.
|
||||
|
||||
### Keyboard Shortcuts in the Modal
|
||||
### Keyboard shortcuts in the modal
|
||||
|
||||
| Key | Action |
|
||||
| ----------- | ---------------------------------- |
|
||||
@@ -364,7 +354,7 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
|
||||
| `Backspace` | Go back from the merge preview |
|
||||
| `Esc` | Cancel (keep both cards unchanged) |
|
||||
|
||||
## Full Config Example
|
||||
## Full config example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -382,11 +372,10 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
|
||||
},
|
||||
"fields": {
|
||||
"word": "Expression",
|
||||
"audio": "ExpressionAudio",
|
||||
"audio": "SentenceAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
"miscInfo": "MiscInfo",
|
||||
"translation": "SelectionText",
|
||||
},
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
@@ -409,11 +398,6 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)",
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
"model": "", // e.g. "openai/gpt-4o-mini"
|
||||
"systemPrompt": "",
|
||||
},
|
||||
"isKiku": {
|
||||
"enabled": false,
|
||||
"fieldGrouping": "disabled",
|
||||
@@ -424,12 +408,5 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
|
||||
"sentenceCardModel": "Lapis",
|
||||
},
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
"apiKey": "",
|
||||
"apiKeyCommand": "",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"requestTimeoutMs": 15000,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
+15
-16
@@ -20,7 +20,7 @@ Within the desktop app, `src/main.ts` is a composition root that wires small run
|
||||
- services compose through explicit inputs/outputs
|
||||
- orchestration is separate from implementation
|
||||
|
||||
## Project Structure
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
||||
@@ -33,7 +33,6 @@ plugin/
|
||||
# state · messages · hover · ui · options · environment · log
|
||||
# binary · session_bindings · version)
|
||||
src/
|
||||
ai/ # AI translation provider utilities (client, config)
|
||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||
main.ts # Entry point - delegates to runtime composers/domain modules
|
||||
preload.ts # Electron preload bridge
|
||||
@@ -74,8 +73,8 @@ src/
|
||||
shared/ipc/ # Cross-process IPC channel constants + payload validators
|
||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||
modals/ # Modal flows (Anime Browser, Jimaku, Kiku, subsync, runtime options,
|
||||
# session help, changelog, character dictionary, playlist browser, subtitle
|
||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||
# changelog, character dictionary, playlist browser, subtitle
|
||||
# sidebar, YouTube track picker, controller config/debug/select)
|
||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||
settings/ # Settings window UI (model, controls, markup)
|
||||
@@ -86,7 +85,7 @@ src/
|
||||
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
|
||||
```
|
||||
|
||||
### Service Layer (`src/core/services/`)
|
||||
### Service layer (`src/core/services/`)
|
||||
|
||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
|
||||
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
||||
@@ -98,7 +97,7 @@ src/
|
||||
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
||||
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
||||
|
||||
### Renderer Layer (`src/renderer/`)
|
||||
### Renderer layer (`src/renderer/`)
|
||||
|
||||
The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delegated to per-concern modules.
|
||||
|
||||
@@ -136,12 +135,12 @@ src/renderer/
|
||||
platform.ts # Layer/platform capability detection
|
||||
```
|
||||
|
||||
### Launcher + Plugin Runtimes
|
||||
### Launcher + plugin runtimes
|
||||
|
||||
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
||||
- `plugin/subminer/main.lua` is the mpv entrypoint: it sets up the module path and loads `init.lua`, a thin shim that boots the modular Lua files: `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `session_bindings.lua` (configurable session keybindings), `version.lua` (version metadata). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
|
||||
|
||||
## Flow Diagram
|
||||
## Flow diagram
|
||||
|
||||
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
||||
|
||||
@@ -224,7 +223,7 @@ flowchart TB
|
||||
style ExtRt fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||
```
|
||||
|
||||
## Composition Pattern
|
||||
## Composition pattern
|
||||
|
||||
Most runtime code follows a dependency-injection pattern:
|
||||
|
||||
@@ -261,14 +260,14 @@ Additional conventions in the current code:
|
||||
- Domain barrels in `src/main/runtime/domains/*` re-export runtime handlers + main-deps builders, while composers in `src/main/runtime/composers/*` assemble larger runtime clusters.
|
||||
- Many runtime handlers accept `*MainDeps` objects generated by `createBuild*MainDepsHandler` builders to isolate side effects and keep units testable.
|
||||
|
||||
### IPC Contract + Validation Boundary
|
||||
### IPC contract + validation boundary
|
||||
|
||||
- Central channel constants live in `src/shared/ipc/contracts.ts` and are consumed by both main (`ipcMain`) and renderer preload (`ipcRenderer`) wiring.
|
||||
- Runtime payload parsers/type guards live in `src/shared/ipc/validators.ts`.
|
||||
- Rule: renderer-supplied payloads must be validated at IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before calling domain handlers.
|
||||
- Malformed invoke payloads return explicit structured errors (for example `{ ok: false, error: ... }`) and malformed fire-and-forget payloads are ignored safely.
|
||||
|
||||
### Runtime State Ownership (Migrated Domains)
|
||||
### Runtime state ownership (migrated domains)
|
||||
|
||||
For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
|
||||
|
||||
@@ -278,7 +277,7 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
||||
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
|
||||
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
|
||||
|
||||
## Playback Startup Flow
|
||||
## Playback startup flow
|
||||
|
||||
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step - the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
|
||||
|
||||
@@ -315,7 +314,7 @@ flowchart TB
|
||||
|
||||
The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
|
||||
|
||||
## Program Lifecycle
|
||||
## Program lifecycle
|
||||
|
||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
||||
@@ -387,7 +386,7 @@ flowchart TB
|
||||
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||
```
|
||||
|
||||
## Subtitle Prefetch Pipeline
|
||||
## Subtitle prefetch pipeline
|
||||
|
||||
SubMiner can pre-tokenize upcoming subtitle lines before they appear on screen. When an external subtitle file (SRT, VTT, or ASS) is detected on the active track, the `SubtitlePrefetchService` parses all cues via the subtitle cue parser (`subtitle-cue-parser.ts`), identifies a priority window of upcoming lines based on the current playback position, and tokenizes them in the background through the same pipeline used for live subtitles. Results are stored directly into the `SubtitleProcessingController` cache, so when a subtitle actually appears during playback, it hits a warm cache and renders in ~30-50ms instead of ~200-320ms.
|
||||
|
||||
@@ -417,7 +416,7 @@ flowchart TB
|
||||
style Render stroke-width:2px
|
||||
```
|
||||
|
||||
## Why This Design
|
||||
## Why this design
|
||||
|
||||
- **Smaller blast radius:** changing one feature usually touches one service.
|
||||
- **Better testability:** most behavior can be tested without Electron windows/mpv.
|
||||
@@ -428,7 +427,7 @@ flowchart TB
|
||||
- **Split MPV service layers:** MPV internals are separated into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and properties/render metrics modules for maintainability.
|
||||
- **Config by domain:** defaults, option registries, and resolution are split by domain under `src/config/definitions/*` and `src/config/resolve/*`, keeping config evolution localized.
|
||||
|
||||
## Extension Rules
|
||||
## Extension rules
|
||||
|
||||
- Add behavior to an existing service in `src/core/services/*` or create a focused runtime module under `src/main/runtime/*`; avoid ad-hoc logic in `main.ts`.
|
||||
- Add new cross-process channels in `src/shared/ipc/contracts.ts` first, validate payloads in `src/shared/ipc/validators.ts`, then wire handlers in IPC runtime modules.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
**Added**
|
||||
|
||||
- **Card Timing Review**:
|
||||
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
|
||||
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
|
||||
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
|
||||
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
|
||||
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Field Grouping**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
|
||||
|
||||
**Changed**
|
||||
|
||||
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
|
||||
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Subtitle & Mining Accuracy**:
|
||||
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
|
||||
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
|
||||
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
|
||||
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
|
||||
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
|
||||
- **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.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Character Dictionary
|
||||
# Character dictionary
|
||||
|
||||
SubMiner can build a Yomitan-compatible character dictionary from [AniList](https://anilist.co) metadata so that character names in subtitles are recognized, highlighted, and enrichable with context - portraits, roles, voice actors, and biographical detail - without leaving the overlay. (AniList is an online anime/manga database; SubMiner pulls each show's character list from it.)
|
||||
SubMiner builds a Yomitan-compatible dictionary of a show's characters from [AniList](https://anilist.co), the online anime and manga database. Once it is loaded, character names in subtitles get recognized and highlighted, and hovering one shows the portrait, role, voice actor, and biography without leaving the overlay.
|
||||
|
||||
This is helpful because proper names rarely appear in normal dictionaries, so character names would otherwise be flagged as "unknown" words and clutter your mining. Recognizing them keeps your N+1 highlighting focused on real vocabulary.
|
||||
Proper names rarely appear in ordinary dictionaries, so without this every character name reads as an unknown word. That wrecks N+1 highlighting, since a line naming two characters looks like a line with two unknowns. Recognizing them keeps the highlighting pointed at real vocabulary.
|
||||
|
||||
The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes available for hover-driven Yomitan profile lookup.
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
The feature has three stages: **snapshot**, **merge**, and **match**.
|
||||
|
||||
@@ -16,12 +16,12 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
|
||||
|
||||
3. **Match** - During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
|
||||
|
||||
## Enabling the Feature
|
||||
## Enabling the feature
|
||||
|
||||
Character dictionary sync is disabled by default. To turn it on:
|
||||
|
||||
1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
|
||||
2. Start watching - SubMiner queries AniList's public GraphQL API (no authentication required) and imports the merged dictionary into Yomitan automatically.
|
||||
2. Start watching. SubMiner queries AniList's public GraphQL API, which needs no authentication, and imports the merged dictionary into Yomitan.
|
||||
3. Optionally enable **Name Match Images** (Settings → Subtitle Style) to show inline circular character portraits next to matched names in subtitles.
|
||||
|
||||
```jsonc
|
||||
@@ -45,7 +45,7 @@ AniList character data is fetched via public GraphQL queries - no account or acc
|
||||
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
|
||||
:::
|
||||
|
||||
## Name Generation
|
||||
## Name generation
|
||||
|
||||
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
|
||||
|
||||
@@ -56,7 +56,7 @@ A single character produces many searchable terms so that names are recognized r
|
||||
- Family name alone: 須々木
|
||||
- Given name alone: 心一
|
||||
|
||||
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
|
||||
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary. That guess can be ambiguous, since 東紫乃 could be 東+紫乃 or 東紫+乃, so SubMiner generates terms for the top two candidate boundaries and the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
|
||||
|
||||
**Middle-dot removal** (common in katakana foreign names):
|
||||
|
||||
@@ -86,7 +86,7 @@ Unspaced native names (AniList often stores 渡辺真奈美 without a separator)
|
||||
|
||||
This means a character like "太郎" generates entries for 太郎, 太郎さん, 太郎先生, 太郎君, 太郎ちゃん, and so on - all with correct readings.
|
||||
|
||||
## Name Matching
|
||||
## Name matching
|
||||
|
||||
Name matching runs inside Yomitan's scanning pipeline during subtitle tokenization.
|
||||
|
||||
@@ -109,7 +109,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||
|
||||
## Inline Character Portraits
|
||||
## Inline character portraits
|
||||
|
||||
When `subtitleStyle.nameMatchImagesEnabled` is enabled, SubMiner injects a small circular portrait image directly into the subtitle line next to each matched character name.
|
||||
|
||||
@@ -128,7 +128,7 @@ The portrait size is controlled by the surrounding subtitle font size and render
|
||||
Inline portraits help you quickly associate names with faces while building vocabulary - especially useful for shows with large casts where you're still learning who's who.
|
||||
:::
|
||||
|
||||
## Dictionary Entries
|
||||
## Dictionary entries
|
||||
|
||||
Each character entry in the Yomitan dictionary includes structured content:
|
||||
|
||||
@@ -156,7 +156,7 @@ The three collapsible sections can be configured to start open or closed:
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Sync Lifecycle
|
||||
## Auto-sync lifecycle
|
||||
|
||||
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
|
||||
|
||||
@@ -185,7 +185,7 @@ These phases are emitted through the configured notification surface. Some phase
|
||||
|
||||
The `maxLoaded` setting (default: 3) controls how many media snapshots stay in the active set. When you start a 4th title, the oldest is evicted and the merged dictionary is rebuilt without it.
|
||||
|
||||
## Manual Generation
|
||||
## Manual generation
|
||||
|
||||
You can generate a character dictionary from the command line without auto-sync:
|
||||
|
||||
@@ -199,7 +199,7 @@ SubMiner.AppImage --dictionary
|
||||
|
||||
This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
|
||||
|
||||
## Correcting AniList Matches
|
||||
## Correcting AniList matches
|
||||
|
||||
SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
||||
|
||||
@@ -223,11 +223,11 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
||||
```
|
||||
|
||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override - including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||
|
||||
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
|
||||
|
||||
## Managing Loaded Entries
|
||||
## Managing loaded entries
|
||||
|
||||
Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
|
||||
|
||||
@@ -237,7 +237,7 @@ Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`).
|
||||
|
||||
The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
|
||||
|
||||
## File Structure
|
||||
## File structure
|
||||
|
||||
All character dictionary data lives under `{userData}/character-dictionaries/`:
|
||||
|
||||
@@ -267,7 +267,7 @@ merged.zip
|
||||
img/ # Embedded character and VA portraits
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
## Configuration reference
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
|
||||
@@ -280,11 +280,11 @@ merged.zip
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||
|
||||
## Reference Implementation
|
||||
## Reference implementation
|
||||
|
||||
SubMiner's character dictionary builder is inspired by the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project - a standalone Rust web service that generates Yomitan character dictionaries from AniList and VNDB data.
|
||||
|
||||
The reference implementation covers similar ground - name variant generation, honorific expansion, structured Yomitan content, portrait embedding - and additionally supports VNDB as a data source for visual novel characters. Key differences:
|
||||
The reference implementation covers the same ground: name variant generation, honorific expansion, structured Yomitan content, and portrait embedding. It also reads VNDB as a source for visual novel characters. Key differences:
|
||||
|
||||
| | SubMiner | Reference Implementation |
|
||||
| ---------------------- | -------------------------------------------- | ------------------------------------- |
|
||||
|
||||
+63
-136
@@ -8,11 +8,13 @@ outline: [2, 3]
|
||||
import { withBase } from 'vitepress';
|
||||
</script>
|
||||
|
||||
SubMiner is configured through a single file (`config.jsonc`). Most settings are also editable from the in-app **Settings** window - you rarely need to edit the file by hand. This page is the full reference: it explains the Settings window, where the config file lives, and documents every option grouped by topic. New to SubMiner? The Quick Start below plus the [Settings window](#settings) cover everything most users need.
|
||||
One file, `config.jsonc`, holds everything. Most of it is also editable from the in-app **Settings** window, so hand-editing is rarely necessary.
|
||||
|
||||
## Quick Start
|
||||
This page is the full reference. It covers the Settings window, where the config file lives, and every option grouped by topic. If you are just starting out, the Quick Start below and the [Settings window](#settings) are enough.
|
||||
|
||||
For most users, start with this minimal configuration:
|
||||
## Quick start
|
||||
|
||||
Start here:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -35,11 +37,11 @@ For most users, start with this minimal configuration:
|
||||
|
||||
Use the known-word deck map to choose which Anki decks and note fields feed the known-word cache.
|
||||
|
||||
Then customize as needed using the sections below.
|
||||
Everything else is optional; the sections below cover it.
|
||||
|
||||
## Settings
|
||||
|
||||
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner - all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
|
||||
Open the **Settings** window from the tray menu, the app's `--settings` flag, or `subminer settings`. It writes straight to `config.jsonc`, so anything you change there is a normal config edit you can inspect afterward.
|
||||
|
||||
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
|
||||
|
||||
@@ -57,11 +59,11 @@ Each field still writes to its current `config.jsonc` path. For example, subtitl
|
||||
|
||||
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
|
||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available.
|
||||
|
||||
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
||||
|
||||
## Configuration File
|
||||
## Configuration file
|
||||
|
||||
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
|
||||
|
||||
@@ -95,7 +97,7 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
|
||||
|
||||
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
|
||||
|
||||
### Hot-Reload Behavior
|
||||
### Hot-reload behavior
|
||||
|
||||
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
||||
|
||||
@@ -103,7 +105,7 @@ Hot-reloadable settings include subtitle appearance, sidebar controls, keybindin
|
||||
shortcuts, notifications, logging level, selected source-language preferences,
|
||||
Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
|
||||
stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default
|
||||
mode, and the Anki deck, known-word, N+1, field, sentence-card, AI, and Kiku options
|
||||
mode, and the Anki deck, known-word, N+1, field, sentence-card, and Kiku options
|
||||
listed in the reference tables below.
|
||||
|
||||
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
|
||||
@@ -111,11 +113,10 @@ When these values change, SubMiner applies them live. Invalid config edits are r
|
||||
Restart-required changes:
|
||||
|
||||
- Any other config sections still require restart.
|
||||
- Shared top-level `ai` provider settings still require restart.
|
||||
- AnkiConnect transport/proxy/media/tag fields still require restart unless listed above.
|
||||
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
|
||||
|
||||
### Configuration Options Overview
|
||||
### Configuration options Overview
|
||||
|
||||
The configuration file includes several main sections:
|
||||
|
||||
@@ -146,7 +147,6 @@ The configuration file includes several main sections:
|
||||
|
||||
**Anki Integration**
|
||||
|
||||
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis/Senren note types
|
||||
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
|
||||
@@ -154,7 +154,6 @@ The configuration file includes several main sections:
|
||||
|
||||
**External Integrations**
|
||||
|
||||
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
@@ -169,7 +168,7 @@ The configuration file includes several main sections:
|
||||
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
|
||||
- [**Notifications**](#notifications) - Overlay notification placement
|
||||
|
||||
## Core Settings
|
||||
## Core settings
|
||||
|
||||
### Logging
|
||||
|
||||
@@ -244,13 +243,13 @@ Configure where overlay notification cards appear:
|
||||
|
||||
#### Notification history panel
|
||||
|
||||
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
|
||||
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use, so left when `overlayPosition` is `"top-left"` and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
|
||||
|
||||
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
|
||||
|
||||
The equivalent direct CLI command is `--playback-feedback <text>` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config.
|
||||
|
||||
### Auto-Start Overlay
|
||||
### Auto-start overlay
|
||||
|
||||
Control whether the overlay automatically becomes visible when it connects to mpv:
|
||||
|
||||
@@ -268,7 +267,7 @@ When you launch through the SubMiner app or the `subminer` wrapper, the launcher
|
||||
|
||||
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
|
||||
|
||||
### Startup Warmups
|
||||
### Startup warmups
|
||||
|
||||
Control which startup warmups run in the background versus deferring to first real usage:
|
||||
|
||||
@@ -294,7 +293,7 @@ Control which startup warmups run in the background versus deferring to first re
|
||||
|
||||
Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExtension`, and `subtitleDictionaries`) with `lowPowerMode: false`; Jellyfin remote session warmup is opt-in (`false` by default). Setting a warmup toggle to `false` defers that work until first usage.
|
||||
|
||||
### WebSocket Server
|
||||
### WebSocket server
|
||||
|
||||
The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
|
||||
|
||||
@@ -358,9 +357,9 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `false`) |
|
||||
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
|
||||
|
||||
## Subtitle Display
|
||||
## Subtitle display
|
||||
|
||||
### Subtitle Style
|
||||
### Subtitle style
|
||||
|
||||
Customize the appearance of primary and secondary subtitles:
|
||||
|
||||
@@ -458,7 +457,7 @@ Secondary subtitle styling lives in the secondary subtitle CSS object. Any CSS p
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||
|
||||
### Subtitle Sidebar
|
||||
### Subtitle sidebar
|
||||
|
||||
Configure the parsed-subtitle sidebar modal.
|
||||
|
||||
@@ -520,7 +519,7 @@ For full details on layout modes, behavior, and the keyboard shortcut, see the [
|
||||
| `N4` | `#8bd5ca` | JLPT N4 underline color |
|
||||
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
||||
|
||||
### Subtitle Position
|
||||
### Subtitle position
|
||||
|
||||
Set the initial vertical subtitle position (measured from the bottom of the screen):
|
||||
|
||||
@@ -538,7 +537,7 @@ Set the initial vertical subtitle position (measured from the bottom of the scre
|
||||
|
||||
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
|
||||
|
||||
### Secondary Subtitles
|
||||
### Secondary subtitles
|
||||
|
||||
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
|
||||
|
||||
@@ -564,8 +563,6 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
||||
|
||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
||||
|
||||
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
|
||||
|
||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||
|
||||
**Display modes:**
|
||||
@@ -576,7 +573,7 @@ The secondary-subtitle language list also acts as the fallback secondary-languag
|
||||
|
||||
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
|
||||
|
||||
## Keyboard & Controls
|
||||
## Keyboard and controls
|
||||
|
||||
### Keybindings
|
||||
|
||||
@@ -593,7 +590,6 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||
| `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Toggle Anime Browser in the player |
|
||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
@@ -635,7 +631,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__anime-browser-open` opens the Anime Browser inside the player bounds. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
|
||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||
|
||||
@@ -643,7 +639,7 @@ Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notifica
|
||||
|
||||
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||
|
||||
### Shortcuts Configuration
|
||||
### Shortcuts configuration
|
||||
|
||||
Customize or disable the overlay keyboard shortcuts:
|
||||
|
||||
@@ -704,7 +700,7 @@ Set any shortcut to `null` to disable it.
|
||||
|
||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||
|
||||
### Controller Support
|
||||
### Controller support
|
||||
|
||||
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
|
||||
|
||||
@@ -820,7 +816,7 @@ If you update this controller documentation or the generated controller examples
|
||||
|
||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||
|
||||
### Manual Card Update Shortcuts
|
||||
### Manual card update shortcuts
|
||||
|
||||
When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||
|
||||
@@ -847,7 +843,7 @@ When automatic card updates are disabled, new cards are detected but not automat
|
||||
|
||||
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
|
||||
|
||||
### Session Help Modal
|
||||
### Session help modal
|
||||
|
||||
The session help modal opens from the overlay with `Ctrl/Cmd+/` by default. The mpv plugin also exposes it through the `y-h` chord. It shows the current session keybindings and color legend.
|
||||
|
||||
@@ -871,13 +867,14 @@ The list is generated at runtime from:
|
||||
|
||||
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
|
||||
|
||||
### Runtime Option Palette
|
||||
### Runtime option palette
|
||||
|
||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||
|
||||
Current runtime options cover automatic card updates, known-word highlighting,
|
||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
||||
Current runtime options cover automatic card updates, media timing review,
|
||||
known-word highlighting, known-word maturity coloring, N+1 annotation, JLPT
|
||||
underlines, frequency highlighting, known-word match mode, and Kiku field
|
||||
grouping mode.
|
||||
|
||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||
|
||||
@@ -890,39 +887,7 @@ Palette controls:
|
||||
- `Enter`: apply selected value
|
||||
- `Esc`: close
|
||||
|
||||
## Anki Integration
|
||||
|
||||
### Shared AI Provider
|
||||
|
||||
This is the single, shared connection to an OpenAI-compatible LLM endpoint. Configure it **once** here at the top level, and SubMiner reuses it wherever AI is needed (Anki translation/enrichment and YouTube subtitle fixing). Per-feature toggles and prompt/model tweaks live in their own sections (for example `ankiConnect.ai` and `youtubeSubgen.ai`) and inherit this transport.
|
||||
|
||||
```json
|
||||
{
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
"apiKey": "",
|
||||
"apiKeyCommand": "",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"requestTimeoutMs": 15000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
||||
| `apiKey` | string | Static API key for the shared provider |
|
||||
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
||||
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
|
||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
|
||||
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
|
||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||
|
||||
SubMiner uses the shared provider for:
|
||||
|
||||
- Anki translation/enrichment when Anki AI is enabled
|
||||
- YouTube generated-subtitle fixing when `youtubeSubgen.fixWithAi` is enabled (with optional `youtubeSubgen.ai.model` / `systemPrompt` overrides)
|
||||
## Anki integration
|
||||
|
||||
### AnkiConnect
|
||||
|
||||
@@ -944,16 +909,10 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"deck": "Learning::Japanese",
|
||||
"fields": {
|
||||
"word": "Expression",
|
||||
"audio": "ExpressionAudio",
|
||||
"audio": "SentenceAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
"miscInfo": "MiscInfo",
|
||||
"translation": "SelectionText"
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
"model": "",
|
||||
"systemPrompt": ""
|
||||
"miscInfo": "MiscInfo"
|
||||
},
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
@@ -969,6 +928,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"reviewTiming": false,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -1010,17 +970,14 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||
| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
@@ -1055,10 +1012,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
|
||||
|
||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||
|
||||
### Kiku/Lapis Integration
|
||||
### Kiku/Lapis integration
|
||||
|
||||
SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings.
|
||||
|
||||
@@ -1086,7 +1040,7 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
||||
- For [Senren](https://github.com/BrenoAqua/Senren) note types, enable `isSenren` instead of `isKiku`. Duplicate merges then use Senren's scene-switching markup (including grouped `miscInfo` entries), and `isSenren.fieldGrouping` supports the same three modes (default: `auto`). Kiku and Senren are mutually exclusive; if both are enabled, Kiku wins and Senren is turned off with a config warning.
|
||||
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
|
||||
|
||||
### Word Card Type
|
||||
### Word card type
|
||||
|
||||
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||
|
||||
@@ -1100,7 +1054,7 @@ When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrich
|
||||
|
||||
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||
|
||||
### N+1 Word Highlighting
|
||||
### N+1 word highlighting
|
||||
|
||||
When known-word highlighting is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
|
||||
|
||||
@@ -1137,7 +1091,7 @@ To refresh roughly once per day, set:
|
||||
}
|
||||
```
|
||||
|
||||
### Field Grouping Modes
|
||||
### Field grouping modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -1156,36 +1110,7 @@ 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>
|
||||
|
||||
## External Integrations
|
||||
|
||||
### Anime Browser
|
||||
|
||||
Sources for the [anime browser](/anime-browser). SubMiner ships no extension repositories and bundles no sources, so these are empty until you add one:
|
||||
|
||||
```json
|
||||
{
|
||||
"anime": {
|
||||
"autoOpenJimaku": false,
|
||||
"extensionsDir": "",
|
||||
"repos": [],
|
||||
"preferredQuality": "",
|
||||
"defaultSource": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `anime.autoOpenJimaku` | `boolean` | `false` | Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes. Playback that was already paused stays paused. |
|
||||
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
||||
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
||||
| `anime.defaultSource` | `string` | `""` | Source the Anime Browser selects when it opens: a source id (`<package>:<source>`) or `all` for every installed source. Empty selects the first installed source. **Set default** in the Extensions tab writes this value. |
|
||||
| `anime.bridgeDir` | `string` | `""` | Directory holding an M-Extension-Server bundle (Java runtime plus server jar) to run instead of the downloaded copy. Empty checks the package-manager install first, then `<userData>/anime-bridge`. See [the bridge](anime-browser.md#the-bridge). |
|
||||
|
||||
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
|
||||
|
||||
Per-source settings (server addresses, credentials, per-extension quality or language options) are not part of `config.jsonc`. They belong to the extension, are edited in the browser's **Source settings** tab, and persist in `<userData>/anime-source-preferences.json` with owner-only permissions.
|
||||
## External integrations
|
||||
|
||||
### Jimaku
|
||||
|
||||
@@ -1227,7 +1152,15 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
|
||||
|
||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
||||
|
||||
### Subtitle Sync
|
||||
### Japanese subtitle generation
|
||||
|
||||
Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings.
|
||||
|
||||
`subtitleGeneration` configures local Japanese transcription for both the launcher and overlay. In **Settings → Integrations → Japanese Subtitle Generation**, set `modelPath` to an existing multilingual whisper.cpp GGML model, or leave it empty and choose a `managedModel` as the default. The generation modal lets you select another model for the current session, with download sizes and accuracy versus speed guidance. Downloads are explicit. Leave `whisperPath`, `ffmpegPath`, and `ffprobePath` empty to find the executables on `PATH`, or set them to override the executable paths. `threads` controls the CPU thread count. Settings apply to the next operation. See [subtitle generation](/subtitle-generation) for setup and behavior, and the [generated configuration example](/config.example.jsonc) for defaults.
|
||||
|
||||
The generation modal offers an optional **Focus on spoken dialogue** checkbox and a separate Silero model download. Set `subtitleGeneration.vadModelPath` to a Silero GGML VAD model to make dialogue mode the default. `vadPath` overrides the speech detector executable. See [dialogue generation setup](/subtitle-generation#prioritizing-spoken-dialogue) for session behavior, the additional tool, and limitations.
|
||||
|
||||
### Subtitle sync
|
||||
|
||||
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
|
||||
@@ -1247,17 +1180,11 @@ Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty auto-discovers `alass` or `alass-cli`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty auto-discovers `ffsubsync`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` auto-discovers `ffmpeg`. |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||
|
||||
Auto-discovery searches `PATH`, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/usr/bin`, `/bin`) — a GUI launch inherits a minimal `PATH` that often omits the first two. Set the option explicitly if your binary lives elsewhere.
|
||||
|
||||
Subtitle tracks that mpv loaded from a URL (Aniyomi extension streams, Jellyfin) are downloaded to a temporary file first, reusing mpv's own request headers, so they can be used as either the sync target or the alass reference.
|
||||
|
||||
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
|
||||
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
Customize it there, or set it to `null` to disable.
|
||||
|
||||
@@ -1421,7 +1348,7 @@ Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same w
|
||||
|
||||
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
|
||||
|
||||
### Discord Rich Presence
|
||||
### Discord rich presence
|
||||
|
||||
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
|
||||
|
||||
@@ -1468,7 +1395,7 @@ Troubleshooting:
|
||||
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
|
||||
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
|
||||
|
||||
### Immersion Tracking
|
||||
### Immersion tracking
|
||||
|
||||
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions. This data also powers the stats dashboard:
|
||||
|
||||
@@ -1542,7 +1469,7 @@ Set `dbPath` only if you want to relocate the database (for backup, syncing, or
|
||||
|
||||
See [Immersion Tracking Storage](/immersion-tracking) for schema details, query templates, dashboard access, retention/rollup behavior, backend portability notes, and the dedicated SQLite verification command.
|
||||
|
||||
### Stats Dashboard
|
||||
### Stats dashboard
|
||||
|
||||
Configure the local stats UI served from SubMiner and the in-app stats overlay toggle:
|
||||
|
||||
@@ -1573,7 +1500,7 @@ Usage notes:
|
||||
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
||||
- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
|
||||
|
||||
### MPV Launcher
|
||||
### MPV launcher
|
||||
|
||||
Configure the mpv executable, profile, and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
|
||||
|
||||
@@ -1615,7 +1542,7 @@ Launch mode behavior:
|
||||
- **`maximized`** - mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
|
||||
- **`fullscreen`** - mpv starts in true fullscreen via `--fullscreen`.
|
||||
|
||||
### YouTube Playback Settings
|
||||
### YouTube playback settings
|
||||
|
||||
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
|
||||
|
||||
@@ -1659,6 +1586,6 @@ Track selection:
|
||||
|
||||
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
|
||||
|
||||
#### YouTube Subtitle Generation (`youtubeSubgen`)
|
||||
#### YouTube subtitle generation (`youtubeSubgen`)
|
||||
|
||||
An advanced, template-hidden section for Whisper-based YouTube subtitle generation: `whisperBin`, `whisperModel`, `whisperVadModel`, `whisperThreads` (default `4`), and `fixWithAi` (default `false`), which post-processes generated subtitles through the [Shared AI Provider](#shared-ai-provider) with optional `youtubeSubgen.ai.model` / `systemPrompt` overrides. These keys are accepted in `config.jsonc` but intentionally omitted from the generated template.
|
||||
An advanced, template-hidden section for Whisper-based YouTube subtitle generation: `whisperBin`, `whisperModel`, `whisperVadModel`, and `whisperThreads` (default `4`). These keys are accepted in `config.jsonc` but the generated template omits them.
|
||||
|
||||
+11
-9
@@ -1,6 +1,8 @@
|
||||
# Feature Demos
|
||||
# Feature demos
|
||||
|
||||
Short recordings of SubMiner's key features and integrations from real playback sessions. A few terms you'll see below: _Yomitan_ is the pop-up dictionary used for word lookups, _Jimaku_ is a community subtitle database, _alass_ and _ffsubsync_ are tools that retime subtitles to match the audio, _Jellyfin_ is a self-hosted media server, and a _texthooker_ is a web page that mirrors the current subtitle as selectable text for browser-based tools.
|
||||
Short recordings from real playback sessions.
|
||||
|
||||
Some vocabulary for what follows. _Yomitan_ is the pop-up dictionary. _Jimaku_ is a community subtitle database. _alass_ and _ffsubsync_ retime subtitles against the audio. _Jellyfin_ is a self-hosted media server. A _texthooker_ is a web page that mirrors the current subtitle as selectable text so browser tools can read it.
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
@@ -8,9 +10,9 @@ import { withBase } from 'vitepress';
|
||||
const v = '20260819-1';
|
||||
</script>
|
||||
|
||||
## Anki Card Mining & Enrichment
|
||||
## Anki card mining and enrichment
|
||||
|
||||
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
||||
Mine a card from Yomitan or straight from a subtitle line. SubMiner attaches the sentence, an audio clip cut to the line timing, and a screenshot.
|
||||
|
||||
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
|
||||
@@ -20,9 +22,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
||||
</a>
|
||||
</video>
|
||||
|
||||
## Subtitle Download & Sync
|
||||
## Subtitle download and sync
|
||||
|
||||
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
||||
Search Jimaku, download a track, then retime it with alass or ffsubsync without leaving SubMiner.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
|
||||
@@ -32,9 +34,9 @@ Search and download subtitles from Jimaku, then retime them with alass or ffsubs
|
||||
::: info VIDEO COMING SOON
|
||||
:::
|
||||
|
||||
## Jellyfin Integration
|
||||
## Jellyfin integration
|
||||
|
||||
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
||||
Browse your Jellyfin library, cast to a device, and start playback from SubMiner. Watch progress goes back to the Jellyfin server.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
|
||||
@@ -46,7 +48,7 @@ Browse your Jellyfin library, cast to devices, and launch playback directly from
|
||||
|
||||
## Texthooker
|
||||
|
||||
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
||||
Mirror subtitles to an external texthooker page so browser extensions can read them while the overlay runs.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Building & Testing
|
||||
# Building and testing
|
||||
|
||||
For internal architecture/workflow guidance, use `docs/README.md` at the repo root. This page stays focused on contributor-facing build and test commands.
|
||||
Architecture and workflow guidance lives in `docs/README.md` at the repo root. This page covers build and test commands only.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -37,7 +37,7 @@ make build-launcher
|
||||
|
||||
`bun run build` includes the Yomitan build step. It builds the bundled Chrome extension directly from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun.
|
||||
|
||||
## Launcher Artifact Workflow
|
||||
## Launcher artifact workflow
|
||||
|
||||
- Source of truth: `launcher/*.ts`
|
||||
- Generated output: `dist/launcher/subminer`
|
||||
@@ -53,7 +53,7 @@ dist/launcher/subminer --help >/dev/null
|
||||
bash scripts/verify-generated-launcher.sh
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
bun run dev # builds + launches with --start --dev
|
||||
@@ -169,7 +169,7 @@ bun run format:check:src
|
||||
- `bun run format:check:src` checks the same scoped set without writing changes.
|
||||
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
|
||||
|
||||
## Config Generation
|
||||
## Config generation
|
||||
|
||||
```bash
|
||||
# Generate default config to ~/.config/SubMiner/config.jsonc (or %APPDATA%\SubMiner\config.jsonc on Windows)
|
||||
@@ -184,7 +184,7 @@ Convenience wrappers still exist:
|
||||
- `make generate-config`
|
||||
- `make generate-example-config`
|
||||
|
||||
## Documentation Site
|
||||
## Documentation site
|
||||
|
||||
The docs site now lives in `docs-site/` inside the main repo.
|
||||
|
||||
@@ -200,7 +200,7 @@ bun run docs:test # Docs regression tests
|
||||
|
||||
Deployment: production docs are built with `bun run docs:build:versioned` and uploaded directly to Cloudflare Pages by the `docs-pages` GitHub Actions workflow using Wrangler (from `.tmp/docs-versioned-site`). Cloudflare's automatic Git-integration deployments are intentionally disabled - see `docs-site/README.md` for the deployment contract. Do not re-enable Pages build settings in the Cloudflare dashboard.
|
||||
|
||||
## Makefile Reference
|
||||
## Makefile reference
|
||||
|
||||
Run `make help` for a full list of targets. Key ones:
|
||||
|
||||
@@ -216,7 +216,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
|
||||
## Contributor Notes
|
||||
## Contributor notes
|
||||
|
||||
- To add/change a config default, edit the matching domain file in `src/config/definitions/defaults-*.ts`.
|
||||
- To add/change config option metadata, edit the matching domain file in `src/config/definitions/options-*.ts`.
|
||||
@@ -228,7 +228,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
- Prefer direct inline deps objects in `src/main/` modules for simple pass-through wiring.
|
||||
- Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping).
|
||||
|
||||
## Environment Variables
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ |
|
||||
|
||||
@@ -57,7 +57,19 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
expect(configurationContents).not.toContain('youtubeSubgen": {\n "mode"');
|
||||
expect(configurationContents).not.toContain('youtubeSubgen.primarySubLanguages');
|
||||
expect(configurationContents).toContain('youtube.primarySubLanguages');
|
||||
expect(configurationContents).toContain('### Shared AI Provider');
|
||||
// The AI provider still exists in src/ai and ankiConnect.ai, but it is not
|
||||
// exposed in the Settings window and is not documented for users. Keep the
|
||||
// user-facing docs free of it so nobody configures a hidden surface.
|
||||
expect(configurationContents).not.toContain('Shared AI Provider');
|
||||
expect(configurationContents).not.toContain('ankiConnect.ai');
|
||||
expect(ankiIntegrationContents).not.toContain('AI Translation');
|
||||
// ankiConnect.fields.translation is a LEGACY_HIDDEN_CONFIG_PATHS key, so it
|
||||
// must not be documented as a current setting.
|
||||
expect(configurationContents).not.toContain('fields.translation');
|
||||
expect(ankiIntegrationContents).not.toContain('SelectionText');
|
||||
// fields.audio holds SubMiner's generated sentence audio; examples should not
|
||||
// point it at the field Yomitan uses for word audio.
|
||||
expect(ankiIntegrationContents).not.toContain('"audio": "ExpressionAudio"');
|
||||
|
||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Immersion Tracking
|
||||
# Immersion tracking
|
||||
|
||||
SubMiner can log your watching and mining activity to a local SQLite database, then surface it in the built-in stats dashboard. Tracking is enabled by default and can be turned off if you do not want local analytics.
|
||||
SubMiner logs your watching and mining activity to a local SQLite database and shows it in the built-in stats dashboard. Tracking is on by default; turn it off if you would rather not keep the data.
|
||||
|
||||
"Immersion" here means time spent watching and reading native Japanese content. **All data stays on your computer** - nothing is uploaded anywhere. (SQLite is just a single-file database; you do not need to install or manage anything.)
|
||||
"Immersion" here means time spent watching and reading native Japanese content. **All of it stays on your machine.** Nothing is uploaded anywhere. SQLite is a single file on disk, so there is no database server to install or run.
|
||||
|
||||
When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains exact lifetime summary tables plus daily/monthly rollups. You can view that data in SubMiner's stats UI or query the database directly with any SQLite tool.
|
||||
Each session records watch time, subtitle lines seen, words encountered, and cards mined. SubMiner also keeps exact lifetime summary tables and daily and monthly rollups. Read it through the stats UI, or point any SQLite tool at the file.
|
||||
|
||||
::: tip For most users
|
||||
Just leave tracking on and use the built-in [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections further down are reference material for advanced users who want to inspect or tune the database - you can safely skip them.
|
||||
Leave tracking on and use the [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections below are reference material for querying or tuning the database yourself. Skip them.
|
||||
:::
|
||||
|
||||
Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_RATIO` (`85%`) value from `src/shared/watch-threshold.ts`.
|
||||
@@ -25,9 +25,9 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
|
||||
|
||||
- Leave `dbPath` empty to use the default location (`immersion.sqlite` in SubMiner's app-data directory).
|
||||
- Set an explicit path to move the database (useful for backups, cloud syncing, or external tools).
|
||||
- To share stats and watch history between two machines, use [`subminer sync <host>`](/launcher-script#sync-between-machines) instead of file-level cloud sync — it merges both databases without one side overwriting the other.
|
||||
- To share stats and watch history between two machines, use [`subminer sync <host>`](/launcher-script#sync-between-machines) instead of file-level cloud sync. It merges both databases instead of letting one side overwrite the other.
|
||||
|
||||
## Stats Dashboard
|
||||
## Stats dashboard
|
||||
|
||||
The same immersion data powers the stats dashboard.
|
||||
|
||||
@@ -37,7 +37,7 @@ The same immersion data powers the stats dashboard.
|
||||
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||
|
||||
### Dashboard Tabs
|
||||
### Dashboard tabs
|
||||
|
||||
#### Overview
|
||||
|
||||
@@ -70,7 +70,7 @@ Open a title and use **Delete Entry** in its header to remove a mistakenly track
|
||||
|
||||
#### Trends
|
||||
|
||||
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
|
||||
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts. Every chart takes a configurable date range and grouping.
|
||||
|
||||

|
||||
|
||||
@@ -108,7 +108,7 @@ Stats server config lives under `stats`:
|
||||
- `markWatchedKey` toggles the watched state of the highlighted entry inside the stats dashboard.
|
||||
- `serverPort` controls the localhost dashboard URL.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
|
||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||
- `autoOpenBrowser` decides whether `subminer stats` opens the dashboard URL in your browser once the server is up.
|
||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
||||
- The background stats daemon is separate from the normal SubMiner overlay app, so you can leave it running and still launch SubMiner later to watch or mine from video.
|
||||
@@ -116,7 +116,7 @@ Stats server config lives under `stats`:
|
||||
- `subminer stats` fails with an error when `immersionTracking.enabled` is `false`.
|
||||
- `subminer stats cleanup` defaults to vocabulary cleanup, repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still fail vocab filtering.
|
||||
|
||||
## Mining Cards from the Stats Page
|
||||
## Mining cards from the stats page
|
||||
|
||||
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:
|
||||
|
||||
@@ -126,13 +126,13 @@ The Search tab and the Vocabulary tab's word detail panel both mine from subtitl
|
||||
|
||||
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
|
||||
|
||||
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
|
||||
Secondary subtitle text is stored alongside primary subtitles during playback, but the Search tab does not use it for display or matching.
|
||||
|
||||
### Word Exclusion List
|
||||
### Word exclusion list
|
||||
|
||||
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
|
||||
|
||||
### Repeated Line Cleanup
|
||||
### Repeated line cleanup
|
||||
|
||||
Karaoke openings and animated signs are authored as one subtitle event per animation frame, all carrying the same text. Playback reports every one of those frames, so a single OP lyric could be recorded hundreds of times and dominate "Top Repeated Words".
|
||||
|
||||
@@ -162,7 +162,7 @@ The cleanup chains runs per line of text, so interleaved dual-line karaoke colla
|
||||
|
||||
Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned.
|
||||
|
||||
## Retention Defaults
|
||||
## Retention defaults
|
||||
|
||||
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
||||
|
||||
@@ -184,9 +184,9 @@ In practice:
|
||||
- Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs.
|
||||
- New-word charts use their own permanent lexical daily rollups, which are not pruned by activity-rollup retention.
|
||||
|
||||
## Storage / Performance Model
|
||||
## Storage / performance model
|
||||
|
||||
The tracker is optimized for "keep everything" defaults:
|
||||
The defaults keep everything, and the schema is shaped around that:
|
||||
|
||||
- Exact all-time totals live in dedicated lifetime summary tables (`imm_lifetime_global`, `imm_lifetime_anime`, `imm_lifetime_media`).
|
||||
- Ended-session totals are persisted onto `imm_sessions`, so most dashboard reads do not need to rescan raw telemetry.
|
||||
@@ -195,7 +195,7 @@ The tracker is optimized for "keep everything" defaults:
|
||||
- Cover-art binaries are deduplicated through a shared blob store so episodes in the same series do not each carry duplicate image bytes.
|
||||
- Hot tables have dedicated indexes for session time ranges, telemetry sample windows, frequency-ranked vocabulary, and cover-art lookup keys.
|
||||
|
||||
## Configurable Knobs
|
||||
## Configurable knobs
|
||||
|
||||
All policy options live under `immersionTracking` in your config:
|
||||
|
||||
@@ -218,7 +218,7 @@ All policy options live under `immersionTracking` in your config:
|
||||
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
|
||||
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
|
||||
|
||||
## Query Templates
|
||||
## Query templates
|
||||
|
||||
### Session timeline
|
||||
|
||||
@@ -316,7 +316,7 @@ ORDER BY rollup_month DESC, video_id DESC
|
||||
LIMIT ?;
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
## Technical details
|
||||
|
||||
- Write path is asynchronous and queue-backed. Hot paths (subtitle parsing, render, token flows) enqueue telemetry and never await SQLite writes.
|
||||
- Queue overflow policy: drop oldest queued writes, keep newest.
|
||||
@@ -327,7 +327,7 @@ LIMIT ?;
|
||||
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
|
||||
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
|
||||
|
||||
### Schema (v18)
|
||||
### Schema (v23)
|
||||
|
||||
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
|
||||
|
||||
@@ -335,6 +335,8 @@ Core tables:
|
||||
|
||||
- `imm_videos` - video key/title/source metadata
|
||||
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables
|
||||
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
|
||||
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
|
||||
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
|
||||
- `imm_session_telemetry` - high-frequency session aggregates over time
|
||||
- `imm_session_events` - event stream with compact numeric event types
|
||||
|
||||
+23
-23
@@ -7,7 +7,7 @@ titleTemplate: Immersion Mining Workflow for MPV
|
||||
hero:
|
||||
name: SubMiner
|
||||
text: Immersion Mining for MPV
|
||||
tagline: Watch media, mine vocabulary, and craft anki cards without leaving the scene.
|
||||
tagline: Watch, look up a word, and get an Anki card with audio and a screenshot. Without pausing your show.
|
||||
image:
|
||||
src: /assets/SubMiner.png
|
||||
alt: SubMiner logo
|
||||
@@ -24,63 +24,63 @@ features:
|
||||
src: /assets/mpv.svg
|
||||
alt: mpv icon
|
||||
title: Built for mpv
|
||||
details: Tracks subtitles via mpv IPC in real time. Launch with the wrapper script or the mpv plugin - no external bridge needed.
|
||||
details: Reads subtitle state over mpv's IPC socket. Launch with the wrapper script or the mpv plugin. There is no separate bridge process to run.
|
||||
link: /usage
|
||||
linkText: How it works
|
||||
- icon:
|
||||
src: /assets/yomitan-icon.svg
|
||||
alt: Yomitan logo
|
||||
title: Bundled Yomitan
|
||||
details: Ships with a built-in Yomitan instance for instant word lookups and context-aware card creation directly from subtitle text.
|
||||
details: A Yomitan instance is bundled and preconfigured. Hover a word in the subtitle overlay to look it up and mine it.
|
||||
link: /mining-workflow
|
||||
linkText: Mining workflow
|
||||
- icon:
|
||||
src: /assets/anki-card.svg
|
||||
alt: Anki card icon
|
||||
title: Anki Card Enrichment
|
||||
details: Auto-fills card fields with sentence, audio clip, screenshot, and translation so you can focus on learning.
|
||||
title: Anki card enrichment
|
||||
details: New cards get the subtitle line, an audio clip cut to the line timing, and a screenshot from that moment.
|
||||
link: /anki-integration
|
||||
linkText: Anki integration
|
||||
- icon:
|
||||
src: /assets/highlight.svg
|
||||
alt: Highlight icon
|
||||
title: Reading Annotations
|
||||
details: N+1 targeting, character-name matching, frequency highlighting, and JLPT tagging - all layered on subtitle text in real time.
|
||||
title: Reading annotations
|
||||
details: N+1 targeting, character-name matching, frequency highlighting, and JLPT tagging, drawn onto the subtitle line as it plays.
|
||||
link: /subtitle-annotations
|
||||
linkText: Annotation details
|
||||
- icon:
|
||||
src: /assets/video.svg
|
||||
alt: Video playback icon
|
||||
title: YouTube Playback
|
||||
details: Play YouTube URLs or ytsearch targets directly - SubMiner automatically selects and loads subtitles for the video.
|
||||
title: YouTube playback
|
||||
details: Pass a YouTube URL or a ytsearch target. SubMiner picks a subtitle track for the video and loads it.
|
||||
link: /usage#youtube-playback
|
||||
linkText: YouTube playback
|
||||
- icon:
|
||||
src: /assets/jellyfin.svg
|
||||
alt: Jellyfin icon
|
||||
title: Jellyfin Integration
|
||||
details: Browse your Jellyfin library, pick media interactively, and play through mpv with full subtitle and mining support.
|
||||
title: Jellyfin integration
|
||||
details: Browse your Jellyfin library from the overlay and play a title through mpv. Subtitles and mining work the same as with local files.
|
||||
link: /jellyfin-integration
|
||||
linkText: Jellyfin setup
|
||||
- icon:
|
||||
src: /assets/subtitle-download.svg
|
||||
alt: Subtitle download icon
|
||||
title: Subtitle Download & Sync
|
||||
details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync - all from the overlay.
|
||||
title: Subtitle download and sync
|
||||
details: Search Jimaku or TsukiHime and download a track, then retime it with alass or ffsubsync. Both run from the overlay.
|
||||
link: /jimaku-integration
|
||||
linkText: Jimaku integration
|
||||
- icon:
|
||||
src: /assets/tokenization.svg
|
||||
alt: Tracking chart icon
|
||||
title: Stats Dashboard
|
||||
details: Browse session history, streak calendars, vocabulary frequency, and per-series progress in a local dashboard - then mine cards straight from your viewing history.
|
||||
title: Stats dashboard
|
||||
details: A local dashboard with session history, streak calendars, word frequency, and per-series progress. You can mine cards from lines you already watched.
|
||||
link: /immersion-tracking
|
||||
linkText: Dashboard & tracking
|
||||
- icon:
|
||||
src: /assets/cross-platform.svg
|
||||
alt: Cross-platform icon
|
||||
title: Cross-Platform
|
||||
details: Runs on Linux (Hyprland, Sway, X11), macOS, and Windows with compositor-aware window positioning and platform-native integration.
|
||||
title: Cross-platform
|
||||
details: Runs on Linux (Hyprland, Sway, X11), macOS, and Windows. Overlay positioning is handled per compositor rather than assuming one window manager.
|
||||
link: /installation
|
||||
linkText: Platform setup
|
||||
---
|
||||
@@ -98,38 +98,38 @@ const demoAssetVersion = '20260819-1';
|
||||
<div class="workflow-step" style="animation-delay: 0ms">
|
||||
<div class="step-number">01</div>
|
||||
<div class="step-title">Start</div>
|
||||
<div class="step-desc">Launch with the wrapper or existing mpv setup and keep subtitles in sync.</div>
|
||||
<div class="step-desc">Launch through the wrapper, or from an mpv setup you already have.</div>
|
||||
</div>
|
||||
<div class="workflow-connector" aria-hidden="true"></div>
|
||||
<div class="workflow-step" style="animation-delay: 60ms">
|
||||
<div class="step-number">02</div>
|
||||
<div class="step-title">Lookup</div>
|
||||
<div class="step-desc">Hover a token in the interactive overlay, then trigger Yomitan lookup to open context.</div>
|
||||
<div class="step-desc">Hover a token in the overlay to open the Yomitan popup for that word.</div>
|
||||
</div>
|
||||
<div class="workflow-connector" aria-hidden="true"></div>
|
||||
<div class="workflow-step" style="animation-delay: 120ms">
|
||||
<div class="step-number">03</div>
|
||||
<div class="step-title">Mine</div>
|
||||
<div class="step-desc">Create cards from Yomitan or mine sentence cards directly from subtitle lines.</div>
|
||||
<div class="step-desc">Add the word from Yomitan, or mine the whole line as a sentence card.</div>
|
||||
</div>
|
||||
<div class="workflow-connector" aria-hidden="true"></div>
|
||||
<div class="workflow-step" style="animation-delay: 180ms">
|
||||
<div class="step-number">04</div>
|
||||
<div class="step-title">Enrich</div>
|
||||
<div class="step-desc">Automatically attach timing-accurate audio, sentence text, and visual evidence.</div>
|
||||
<div class="step-desc">SubMiner fills in the audio clip, the sentence, and a screenshot from that moment.</div>
|
||||
</div>
|
||||
<div class="workflow-connector" aria-hidden="true"></div>
|
||||
<div class="workflow-step" style="animation-delay: 240ms">
|
||||
<div class="step-number">05</div>
|
||||
<div class="step-title">Track</div>
|
||||
<div class="step-desc">Open the stats dashboard to review sessions, vocabulary trends, and mine cards from past viewing history.</div>
|
||||
<div class="step-desc">Review past sessions and word trends, and mine anything you missed the first time.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="demo-section">
|
||||
<h2>See it in action</h2>
|
||||
<p>Subtitles, lookup flow, and card enrichment from a real playback session.</p>
|
||||
<p>Recorded from an actual playback session: subtitle hover, lookup, and the card that comes out the other end.</p>
|
||||
<div class="demo-window">
|
||||
<div class="demo-window__bar">
|
||||
<span class="demo-window__dot"></span>
|
||||
|
||||
+29
-27
@@ -1,6 +1,8 @@
|
||||
# Installation
|
||||
|
||||
SubMiner is a desktop app that draws an interactive layer - an **overlay** - on top of the [mpv](https://mpv.io) video player. As you watch native Japanese media, you can click or hover any word in the subtitles to look it up, then turn it into an Anki flashcard without pausing to switch apps. Building flashcards from real content you're watching is called **sentence mining**, and it's what SubMiner is built for. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (an add-on that lets other programs add cards to Anki) so cards get filled in automatically.
|
||||
SubMiner draws an interactive overlay on top of the [mpv](https://mpv.io) video player. While you watch Japanese media, hover any word in the subtitles to look it up, then turn it into an Anki card without switching apps.
|
||||
|
||||
Building cards from the content you are actually watching is called **sentence mining**, and it is the whole point of SubMiner. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (the add-on that lets other programs write cards into Anki), so the sentence, audio, and screenshot fields get filled in for you.
|
||||
|
||||
Three steps to get started:
|
||||
|
||||
@@ -8,11 +10,15 @@ Three steps to get started:
|
||||
2. **Install SubMiner** - from the AUR, or download from GitHub Releases
|
||||
3. **Launch the app** - first-run setup walks you through dictionaries, the launcher, and everything else
|
||||
|
||||
## 1. Install Requirements
|
||||
## 1. Install requirements
|
||||
|
||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||
Only **mpv** is strictly required. Everything else is optional, though you will want ffmpeg unless you are fine with cards that have no audio or screenshot.
|
||||
|
||||
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
|
||||
Some rows below matter only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut, so skip those.
|
||||
|
||||
[Local Japanese subtitle generation](/subtitle-generation) additionally requires whisper.cpp's `whisper-cli`, FFmpeg, and `ffprobe`. Configure their executable paths in Settings if needed. SubMiner can download a speech model explicitly, or use your existing multilingual GGML model.
|
||||
|
||||
Optional [dialogue-focused generation](/subtitle-generation#prioritizing-spoken-dialogue) also uses whisper.cpp's speech segment detector and a separate Silero GGML VAD model.
|
||||
|
||||
| Dependency | Status | Platforms | What it does |
|
||||
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -39,7 +45,7 @@ Several entries below exist only for the `subminer` command-line launcher, which
|
||||
- **X11 / Xwayland** - for X11 sessions or any other Wayland compositor (uses `xdotool` and `xwininfo`)
|
||||
|
||||
::: warning Wayland support is compositor-specific
|
||||
Wayland has no universal API for window positioning - each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Only Hyprland and Sway have native Wayland backends. If you run a different Wayland compositor (GNOME, KDE Plasma, river, etc.), both mpv **and** SubMiner must run under X11 or Xwayland. The `subminer` launcher handles this automatically when `--backend x11` is set or the X11 backend is auto-detected.
|
||||
Wayland has no universal API for window positioning. Each compositor exposes its own IPC, so SubMiner needs a backend per compositor. Only Hyprland and Sway have native Wayland backends. If you run a different Wayland compositor (GNOME, KDE Plasma, river, etc.), both mpv **and** SubMiner must run under X11 or Xwayland. The `subminer` launcher handles this automatically when `--backend x11` is set or the X11 backend is auto-detected.
|
||||
:::
|
||||
|
||||
<details>
|
||||
@@ -215,10 +221,6 @@ Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from t
|
||||
paru -S subminer-bin
|
||||
```
|
||||
|
||||
For the [anime browser](anime-browser.md#the-bridge), optionally add
|
||||
`mangatan-extension-server`. SubMiner picks the package up instead of
|
||||
downloading its own copy of the bridge, and pacman keeps it updated.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
@@ -264,7 +266,7 @@ First-run setup can install [Bun](https://bun.sh) and the `subminer` command-lin
|
||||
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-macos).
|
||||
:::
|
||||
|
||||
### Windows (Installer) {#windows-installer}
|
||||
### Windows (installer) {#windows-installer}
|
||||
|
||||
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
||||
|
||||
@@ -273,7 +275,7 @@ Download the latest installer from [GitHub Releases](https://github.com/ksyasuda
|
||||
|
||||
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
|
||||
|
||||
### From Source
|
||||
### From source
|
||||
|
||||
<details>
|
||||
<summary><b>Linux</b></summary>
|
||||
@@ -325,9 +327,9 @@ bun run build:win
|
||||
|
||||
</details>
|
||||
|
||||
## 3. Launch & First-Run Setup
|
||||
## 3. Launch and first-run setup
|
||||
|
||||
Launch SubMiner and the setup wizard will open automatically:
|
||||
Launch SubMiner and the setup wizard opens on its own:
|
||||
|
||||
```bash
|
||||
# Linux (AUR install)
|
||||
@@ -354,7 +356,7 @@ The `Finish setup` button requires a config file and at least one Yomitan dictio
|
||||
> [!TIP]
|
||||
> You can re-open the setup wizard at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
|
||||
|
||||
### Play a Video
|
||||
### Play a video
|
||||
|
||||
Once setup is complete:
|
||||
|
||||
@@ -362,13 +364,13 @@ Once setup is complete:
|
||||
subminer video.mkv
|
||||
```
|
||||
|
||||
You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay.
|
||||
The overlay appears over mpv. If a subtitle track loaded, its text shows up in the overlay as hoverable words.
|
||||
|
||||
On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup - double-click it, or drag a video file onto it.
|
||||
|
||||
### Verify Setup
|
||||
### Verify setup
|
||||
|
||||
Run the built-in diagnostic to confirm everything is working:
|
||||
Run the built-in diagnostic:
|
||||
|
||||
```bash
|
||||
subminer doctor
|
||||
@@ -376,7 +378,7 @@ subminer doctor
|
||||
|
||||
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
|
||||
|
||||
## Anki Setup (Recommended)
|
||||
## Anki setup (recommended)
|
||||
|
||||
If you plan to mine Anki cards:
|
||||
|
||||
@@ -402,15 +404,15 @@ The tray "Check for Updates" entry installs the new app automatically on Linux,
|
||||
|
||||
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||
|
||||
## How It All Fits Together
|
||||
## How it all fits together
|
||||
|
||||
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
|
||||
SubMiner is an overlay window that sits on top of mpv. It talks to mpv over an IPC socket, renders each subtitle line as interactive text backed by the bundled Yomitan dictionary engine, and writes Anki cards through AnkiConnect when you ask it to.
|
||||
|
||||
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
||||
|
||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||
SubMiner injects the bundled mpv plugin at runtime, so there is nothing to install separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. The plugin adds in-player keybindings (the `y` chord) for driving the overlay from mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||
|
||||
## Platform Notes
|
||||
## Platform notes
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -419,9 +421,9 @@ The bundled mpv plugin is injected at runtime automatically - you don't need to
|
||||
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
||||
- Intel: `/usr/local/bin/mecab`
|
||||
|
||||
Ensure `mecab` is available on your PATH when launching SubMiner.
|
||||
`mecab` has to be on your PATH when SubMiner launches.
|
||||
|
||||
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
|
||||
**Fullscreen:** The overlay follows mpv into fullscreen. If it does not, accessibility permission is the usual cause.
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -430,7 +432,7 @@ Ensure `mecab` is available on your PATH when launching SubMiner.
|
||||
- IPC socket on Windows is `\\.\pipe\subminer-socket` - do not use `/tmp/subminer-socket`.
|
||||
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
|
||||
|
||||
## Manual Launcher Install
|
||||
## Manual launcher install
|
||||
|
||||
The `subminer` launcher uses a [Bun](https://bun.sh) shebang, so Bun must be installed. First-run setup can handle this automatically, but if you prefer to do it yourself:
|
||||
|
||||
@@ -456,9 +458,9 @@ sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/sub
|
||||
sudo chmod +x /usr/local/bin/subminer
|
||||
```
|
||||
|
||||
## Optional Extras
|
||||
## Optional extras
|
||||
|
||||
### Linux Support Assets
|
||||
### Linux support assets
|
||||
|
||||
SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||
|
||||
|
||||
+11
-11
@@ -1,10 +1,10 @@
|
||||
# IPC + Runtime Contracts
|
||||
# IPC + runtime contracts
|
||||
|
||||
SubMiner's Electron app runs two isolated processes - main and renderer - that can only communicate through IPC channels. This boundary is intentional: the renderer is an untrusted surface (it loads Yomitan, renders user-controlled subtitle text, and runs in a Chromium sandbox), so every message crossing the bridge passes through a validation layer before it can reach domain logic.
|
||||
SubMiner's Electron app runs two isolated processes, main and renderer, and IPC channels are the only way they talk. That boundary is deliberate. The renderer is an untrusted surface: it loads Yomitan, renders subtitle text SubMiner did not write, and runs in a Chromium sandbox. Every message crossing the bridge goes through a validator before any domain code sees it.
|
||||
|
||||
The contract system enforces this by making channel names, payload shapes, and validators co-located and co-evolved. A change to any IPC surface touches the contract, the validator, the preload bridge, and the handler in the same commit - drift between any of those layers is treated as a bug.
|
||||
Channel names, payload shapes, and validators all live together, so they change together. Touching an IPC surface means updating the contract, the validator, the preload bridge, and the handler in one commit. Drift between those four layers is a bug, not a style preference.
|
||||
|
||||
## Message Flow
|
||||
## Message flow
|
||||
|
||||
Renderer-initiated calls (`invoke`) pass through four boundaries before reaching a service. Fire-and-forget messages (`send`) follow the same path but skip the response leg. Malformed payloads are caught at the validator and never reach domain code.
|
||||
|
||||
@@ -36,7 +36,7 @@ flowchart TB
|
||||
style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
```
|
||||
|
||||
## Runtime Sockets
|
||||
## Runtime sockets
|
||||
|
||||
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
|
||||
|
||||
@@ -67,7 +67,7 @@ flowchart LR
|
||||
|
||||
How these sockets are established during launch is covered in [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
## Core Surfaces
|
||||
## Core surfaces
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
@@ -79,7 +79,7 @@ How these sockets are established during launch is covered in [Playback Startup
|
||||
| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
|
||||
| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
|
||||
|
||||
## Contract Rules
|
||||
## Contract rules
|
||||
|
||||
These rules exist to prevent a class of bugs where the renderer and main process silently disagree about message shapes - which surfaces as undefined fields, swallowed errors, or state corruption.
|
||||
|
||||
@@ -89,13 +89,13 @@ These rules exist to prevent a class of bugs where the renderer and main process
|
||||
- **Keep payloads narrow.** Send only what the handler needs. Avoid passing entire state objects across the bridge - it couples the renderer to internal main-process structure.
|
||||
- **Co-evolve all layers.** When a payload shape changes, update `contracts.ts`, `validators.ts`, `preload.ts`, and the handler in the same commit. Partial updates are treated as bugs.
|
||||
|
||||
## Two Message Patterns
|
||||
## Two message patterns
|
||||
|
||||
**Invoke (request/response):** The renderer calls a typed bridge method and awaits a result. The main process validates the payload, runs the handler, and returns a structured response. Used for operations where the renderer needs a result - lookups, config reads, mining actions.
|
||||
|
||||
**Fire-and-forget (send):** The renderer sends a message with no response. The main process validates and handles it silently. Malformed payloads are dropped. Used for notifications where the renderer doesn't need confirmation - UI state hints, focus events, position updates.
|
||||
|
||||
## Add a New IPC Action
|
||||
## Add a new IPC action
|
||||
|
||||
1. Add the channel constant in `src/shared/ipc/contracts.ts`.
|
||||
2. Add or extend the payload validator in `src/shared/ipc/validators.ts`.
|
||||
@@ -104,7 +104,7 @@ These rules exist to prevent a class of bugs where the renderer and main process
|
||||
5. Add tests for both valid and malformed payload cases in `src/core/services/*`.
|
||||
6. Update renderer tests when behavior or state transitions change.
|
||||
|
||||
## Runtime State Notes
|
||||
## Runtime state notes
|
||||
|
||||
- Prefer runtime/domain composition via `src/main/runtime/composers/*` and `src/main/runtime/domains/*`. IPC handlers should delegate to composers rather than containing orchestration logic.
|
||||
- Route shared mutable state updates through transition helpers in `src/main/state.ts` for migrated domains. Direct mutation from IPC handlers bypasses invariant checks.
|
||||
@@ -116,7 +116,7 @@ These rules exist to prevent a class of bugs where the renderer and main process
|
||||
- **Renderer invoke fails:** Verify the preload bridge method exists and matches the channel constant. Check that the handler is registered and returning (not throwing).
|
||||
- **Contract drift:** When invoke calls return unexpected shapes, compare the shared contract, validator, preload bridge, and main handler signatures side by side. One of them was updated without the others.
|
||||
|
||||
## Related Docs
|
||||
## Related docs
|
||||
|
||||
- [Architecture](/architecture)
|
||||
- [Development](/development)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Jellyfin Integration
|
||||
# Jellyfin integration
|
||||
|
||||
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server - think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay.
|
||||
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server, a private streaming service for video you already own. If your anime lives on a Jellyfin server, SubMiner plays episodes from it through mpv with the mining overlay attached.
|
||||
|
||||
::: tip Who needs this?
|
||||
This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point.
|
||||
This page only matters if you already run a Jellyfin server or have access to one. Watching local files or YouTube? Skip it. Otherwise start with the in-app setup window (`subminer jellyfin`).
|
||||
:::
|
||||
|
||||
SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on…" / cast menu of any Jellyfin app - web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup.
|
||||
SubMiner can register itself as a **cast-to-device target**, the way jellyfin-mpv-shim does. Sign in once, turn on discovery, and SubMiner appears in the "Play on" menu of any Jellyfin client, whether that is the web app, your phone, or a TV. Cast an episode and it opens in SubMiner's mpv window with the overlay and Yomitan lookup live.
|
||||
|
||||
This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end.
|
||||
|
||||
@@ -18,11 +18,11 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Start SubMiner
|
||||
### 1. start SubMiner
|
||||
|
||||
Launch SubMiner so it's running in the system tray.
|
||||
Launch SubMiner and leave it in the system tray.
|
||||
|
||||
### 2. Sign in to your server
|
||||
### 2. sign in to your server
|
||||
|
||||
Open the tray menu and click **Configure Jellyfin**. In the window that opens, enter your **Server URL** (for example `http://127.0.0.1:8096`), **Username**, and **Password**, then click **Login**.
|
||||
|
||||
@@ -34,14 +34,14 @@ On success, SubMiner:
|
||||
|
||||
Reopen this window any time to switch servers or **Logout**.
|
||||
|
||||
### 3. Turn on discovery
|
||||
### 3. turn on discovery
|
||||
|
||||
Discovery is what makes SubMiner appear as a cast target. Two ways to enable it:
|
||||
|
||||
- **For the current session** - open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.)
|
||||
- **Automatically on every launch** - already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings).
|
||||
|
||||
### 4. Cast from any Jellyfin app
|
||||
### 4. cast from any Jellyfin app
|
||||
|
||||
In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device - SubMiner appears there named after your computer's hostname. Playback opens in SubMiner.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Jimaku Integration
|
||||
# Jimaku integration
|
||||
|
||||
[Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime - a shared online library of subtitle files contributed by other learners. SubMiner integrates with the Jimaku API so you can search, browse, and download Japanese subtitle files directly from the overlay - no alt-tabbing or manual file management required. Downloaded subtitles are loaded into mpv immediately.
|
||||
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
|
||||
|
||||
::: tip Prerequisite: a free API key
|
||||
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
The Jimaku integration runs through an in-overlay modal accessible via a keyboard shortcut (`Ctrl+Shift+J` by default).
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number. Common naming conventions are supported - `S01E03`, `1x03`, `E03`, and dash-separated episode numbers all work. If the filename yields a high-confidence match (title + episode), SubMiner auto-searches immediately.
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number. It handles `S01E03`, `1x03`, `E03`, and dash-separated episode numbers. If the filename yields a high-confidence match (title + episode), SubMiner auto-searches immediately.
|
||||
|
||||
From there:
|
||||
|
||||
@@ -21,14 +21,14 @@ From there:
|
||||
|
||||
If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry.
|
||||
|
||||
### Modal Keyboard Shortcuts
|
||||
### Modal keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ------------------------- | ---------------------------- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select entry / download file |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
|
||||
| `Escape` | Close modal |
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select entry / download file |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
|
||||
| `Escape` | Close modal |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -41,30 +41,30 @@ Add a `jimaku` section to your `config.jsonc`:
|
||||
"apiKeyCommand": "cat ~/.jimaku_key",
|
||||
"apiBaseUrl": "https://jimaku.cc",
|
||||
"languagePreference": "ja",
|
||||
"maxEntryResults": 10,
|
||||
},
|
||||
"maxEntryResults": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
|
||||
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
|
||||
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
|
||||
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
|
||||
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
|
||||
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
|
||||
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
|
||||
|
||||
The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"shortcuts": {
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
},
|
||||
"openJimaku": "Ctrl+Shift+J"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Key
|
||||
### API key
|
||||
|
||||
An API key is required to use the Jimaku integration. You can get one from [jimaku.cc](https://jimaku.cc). There are two ways to provide it:
|
||||
|
||||
@@ -73,14 +73,12 @@ An API key is required to use the Jimaku integration. You can get one from [jima
|
||||
|
||||
If both are set, `apiKey` takes priority.
|
||||
|
||||
## Filename Parsing
|
||||
## Filename parsing
|
||||
|
||||
SubMiner extracts media info from the current video path to pre-fill the search fields. The parser handles:
|
||||
|
||||
- **Season + episode patterns:** `S01E03`, `1x03`
|
||||
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
|
||||
- **Spelled-out episode labels:** `Episode 4`, `第4話`
|
||||
- **Season named at the end of the title:** `… Season 3`, `… 3rd Season`, `… S3`, `… 第3期` - the season goes in the Season field instead of being searched for as part of the series name
|
||||
- **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one
|
||||
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
|
||||
- **Year tags:** `(2024)` - stripped
|
||||
@@ -89,8 +87,6 @@ SubMiner extracts media info from the current video path to pre-fill the search
|
||||
|
||||
If the parser produces a high-confidence result (title + episode both detected), the search runs automatically when the modal opens. Otherwise, you can adjust the fields manually before searching.
|
||||
|
||||
Episodes launched from the [anime browser](/anime-browser) skip parsing entirely: Title, Season, and Episode come from the source's own listing, which is why the search runs immediately even though the stream URL says nothing about the episode.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Jimaku API key not set"**
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Launcher Script
|
||||
# Launcher script
|
||||
|
||||
The `subminer` launcher is an all-in-one script that handles video selection, mpv startup, and overlay management. It is the recommended way to use SubMiner on Linux and macOS because it guarantees mpv is launched with the correct IPC socket and SubMiner defaults. It's a Bun script distributed as a release asset alongside the AppImage and DMG.
|
||||
The `subminer` launcher handles video selection, mpv startup, and overlay management in one script. Use it on Linux and macOS: it is the only path that guarantees mpv comes up with the right IPC socket and SubMiner's defaults. It is a Bun script, shipped as a release asset next to the AppImage and DMG.
|
||||
|
||||
::: tip Windows users
|
||||
On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup - double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
|
||||
:::
|
||||
|
||||
## Video Picker
|
||||
## Video picker
|
||||
|
||||
When you run `subminer` without specifying a file, it opens an interactive video picker. By default it uses **fzf** in the terminal; pass `-R` to use **rofi** instead.
|
||||
Run `subminer` with no file and it opens an interactive picker. That is **fzf** in the terminal by default, or **rofi** with `-R`.
|
||||
|
||||
### fzf (default)
|
||||
|
||||
@@ -66,7 +66,7 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
|
||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
||||
```
|
||||
|
||||
## Watch History
|
||||
## Watch history
|
||||
|
||||
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
|
||||
|
||||
@@ -87,7 +87,7 @@ After an episode ends or you close mpv, the launcher returns to an action menu f
|
||||
|
||||
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
|
||||
|
||||
## Sync Between Machines
|
||||
## Sync between machines
|
||||
|
||||
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app. The command-line launcher is optional everywhere.
|
||||
|
||||
@@ -135,7 +135,7 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t
|
||||
|
||||
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 60 minutes), including during active playback; results surface as overlay notifications. The unfinished playback session is skipped until a later sync sees it finalized. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
|
||||
|
||||
## Common Commands
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
subminer video.mkv # play a specific file (managed launches auto-start the visible overlay by default)
|
||||
@@ -159,6 +159,7 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer generate-subs [video]` | Generate [Japanese subtitles](/usage#generate-japanese-subtitles-locally) locally |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Mining Workflow
|
||||
# Mining workflow
|
||||
|
||||
This guide walks through the sentence mining loop - from watching a video to creating Anki cards with audio, screenshots, and context.
|
||||
This guide walks the whole sentence mining loop, from starting a video to ending up with an Anki card that has audio, a screenshot, and the surrounding sentence.
|
||||
|
||||
## Overview
|
||||
|
||||
_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||
_Sentence mining_ means turning sentences you hit while watching native video into Anki cards, so you learn a word in the context where you first met it. The idea is old. The tedious part is everything between spotting the word and having a finished card, and that is the part SubMiner does for you.
|
||||
|
||||
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
|
||||
SubMiner draws a transparent overlay on top of mpv and renders each subtitle line as interactive text. Hover a word, trigger a Yomitan lookup with your configured key or modifier, then add the card. SubMiner attaches the sentence, an audio clip, and a screenshot on its own, so there is nothing to copy-paste or screenshot by hand.
|
||||
|
||||
> **Yomitan** is the popup dictionary that shows definitions when you hover or scan a word. **AnkiConnect** is the add-on that lets SubMiner talk to Anki. Both are set up during installation - see [Anki Integration](/anki-integration) if you have not configured them yet.
|
||||
|
||||
## Creating Anki Cards
|
||||
## Creating Anki cards
|
||||
|
||||
There are four ways to create or enrich cards, depending on your workflow.
|
||||
|
||||
### 1. Auto-Update from Yomitan
|
||||
### 1. Auto-update from Yomitan
|
||||
|
||||
This is the most common flow. Yomitan creates a card in Anki, and SubMiner enriches it automatically.
|
||||
|
||||
@@ -27,23 +27,22 @@ This is the most common flow. Yomitan creates a card in Anki, and SubMiner enric
|
||||
- **Sentence**: The current subtitle line.
|
||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus optional configured padding).
|
||||
- **Image**: A screenshot or animated clip from the current playback position.
|
||||
- **Translation**: From the secondary subtitle track, or generated via AI if configured.
|
||||
- **MiscInfo**: Metadata like filename and timestamp.
|
||||
|
||||
Configure which fields to fill in `ankiConnect.fields`. See [Anki Integration](/anki-integration) for details.
|
||||
|
||||
### 2. Manual Update from Clipboard
|
||||
### 2. manual update from clipboard
|
||||
|
||||
If you prefer a hands-on approach (animecards-style), you can copy the current subtitle to the clipboard and then paste it onto the last-added Anki card:
|
||||
|
||||
1. Add a word via Yomitan as usual.
|
||||
2. Press `Ctrl/Cmd+C` to copy the current subtitle line to the clipboard.
|
||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill.
|
||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio and image, the same fields auto-update would fill.
|
||||
|
||||
Manual clipboard updates always replace generated sentence audio in `ankiConnect.fields.audio`, even when `ankiConnect.behavior.overwriteAudio` is disabled. Normal word-card updates use the configured sentence and audio fields even when Lapis or Kiku support is enabled.
|
||||
|
||||
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
||||
Use this when auto-update is off, or when the line you want on the card is not the line currently on screen.
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| -------------------------- | ------------------------------- | --------------------------------------- |
|
||||
@@ -51,7 +50,7 @@ This is useful when auto-update is disabled or when you want explicit control ov
|
||||
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
|
||||
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |
|
||||
|
||||
### 3. Mine Sentence (Hotkey)
|
||||
### 3. mine Sentence (hotkey)
|
||||
|
||||
Create a standalone sentence card without going through Yomitan:
|
||||
|
||||
@@ -64,7 +63,7 @@ The sentence card uses the note type configured in `isLapis.sentenceCardModel` a
|
||||
Sentence card creation requires `ankiConnect.isLapis.sentenceCardModel` to name a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type that exists in Anki (default: `"Lapis"`). See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
|
||||
:::
|
||||
|
||||
### 4. Mark as Audio Card
|
||||
### 4. mark as audio card
|
||||
|
||||
After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+A` by default, `shortcuts.markAudioCard`) to mark the card as an audio card. This sets the audio-card flag and fills sentence, image, and metadata fields alongside the full-subtitle audio clip.
|
||||
|
||||
@@ -72,9 +71,9 @@ After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+
|
||||
Audio card marking uses the same `ankiConnect.isLapis.sentenceCardModel` note type as sentence cards. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
|
||||
:::
|
||||
|
||||
### Field Grouping (Kiku/Senren)
|
||||
### Field grouping (Kiku/Senren)
|
||||
|
||||
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching).
|
||||
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This is built for [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching).
|
||||
|
||||
1. You add a word via Yomitan.
|
||||
2. SubMiner detects the new card and checks if a card with the same expression already exists.
|
||||
@@ -84,15 +83,15 @@ If you mine the same word from different sentences, SubMiner can merge the cards
|
||||
|
||||
See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku-senren) for configuration options, merge behavior, and modal keyboard shortcuts.
|
||||
|
||||
## Overlay Model
|
||||
## Overlay model
|
||||
|
||||
SubMiner uses one overlay window with modal surfaces. It carries two subtitle bars - a primary reading bar and a secondary translation/context bar - plus modal dialogs that open on top.
|
||||
|
||||
Toggle the entire overlay window with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
|
||||
### Primary Subtitle Layer
|
||||
### Primary subtitle layer
|
||||
|
||||
The primary bar renders subtitles as tokenized hoverable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||
The primary bar renders each subtitle as separate hoverable word spans, each carrying its reading and headword. Its styling is independent of mpv's own subtitle rendering. It supports:
|
||||
|
||||
- Word-level hover targets for Yomitan lookup
|
||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||
@@ -101,20 +100,17 @@ The primary bar renders subtitles as tokenized hoverable word spans. Each word i
|
||||
- Right-click + drag to reposition subtitles
|
||||
- **Reading annotations** - known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted
|
||||
|
||||
### Secondary Subtitle Bar
|
||||
### Secondary subtitle bar
|
||||
|
||||
The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track (typically English) for translation/context while keeping the primary reading flow below. It is useful for:
|
||||
|
||||
- Quick comprehension checks without leaving the mining flow.
|
||||
- Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
||||
The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track, usually English, above the primary reading line. Use it to sanity-check your comprehension without breaking out of the mining flow.
|
||||
|
||||
For local media, SubMiner can parse supported embedded secondary tracks into timed cues. For remote URLs and files on network mounts, it uses mpv's live secondary subtitle text instead of scanning the media with ffmpeg.
|
||||
|
||||
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
|
||||
The `secondarySub` config controls it, and it opens and closes with the main overlay window. Cycle which track feeds it with `Shift+J`.
|
||||
|
||||
SubMiner collapses duplicate ASS layers in parsed secondary tracks. Exact repeated lines collapse at any length, while distinct simultaneous short lines remain separate. Long dialogue and positioned-sign copies also collapse when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar.
|
||||
|
||||
### Display Modes
|
||||
### Display modes
|
||||
|
||||
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
||||
|
||||
@@ -131,11 +127,11 @@ Cycle each bar's mode at runtime with its own shortcut:
|
||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
|
||||
### Modal Surfaces
|
||||
### Modal surfaces
|
||||
|
||||
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||
|
||||
## Looking Up Words
|
||||
## Looking up words
|
||||
|
||||
1. Hover over the subtitle area - the overlay activates pointer events.
|
||||
2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly.
|
||||
@@ -143,7 +139,7 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
||||
4. Yomitan opens its lookup popup for the hovered token.
|
||||
5. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
### Controller workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
@@ -155,11 +151,11 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
|
||||
6. **Close** - press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** - press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
|
||||
After controller support is enabled, the controller and keyboard can be used interchangeably - switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
Once controller support is on, the controller and keyboard both stay live. You can drop the controller mid-episode and keep going with the keyboard. Toggle keyboard-only mode with `Y` on the controller.
|
||||
|
||||
See [Usage - Controller Support](/usage#controller-support) for setup details and [Configuration - Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
|
||||
## Subtitle Sync (Subsync)
|
||||
## Subtitle sync (subsync)
|
||||
|
||||
If your subtitle file is out of sync with the audio, SubMiner can resynchronize it using [alass](https://github.com/kaegi/alass) or [ffsubsync](https://github.com/smacke/ffsubsync).
|
||||
|
||||
@@ -173,24 +169,22 @@ The reference and the out-of-sync subtitle must be different tracks; the referen
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
||||
|
||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||
|
||||
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||
|
||||
## Texthooker
|
||||
|
||||
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (fixed default port; overridable only via the mpv plugin's `texthooker_port` script-opt) that serves a texthooker UI. This allows external tools - such as a browser-based Yomitan instance - to receive subtitle text in real time.
|
||||
SubMiner serves a texthooker UI from a local HTTP server at `http://127.0.0.1:5174`. The port is fixed unless you override it with the mpv plugin's `texthooker_port` script-opt. External tools read subtitle text from it as lines arrive, which is how you would feed a browser-based Yomitan instance.
|
||||
|
||||
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
|
||||
|
||||
If you want to build your own browser client, websocket consumer, or automation relay, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||
|
||||
## Related Features
|
||||
## Related features
|
||||
|
||||
These features support the mining loop but have their own dedicated pages:
|
||||
These feed into the mining loop but each has its own page:
|
||||
|
||||
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
||||
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - reads your Anki decks and highlights words you already know, so a line with exactly one unknown word stands out while you watch.
|
||||
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
||||
|
||||
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
||||
|
||||
+10
-10
@@ -1,12 +1,12 @@
|
||||
# MPV Plugin
|
||||
# MPV plugin
|
||||
|
||||
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
|
||||
The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv. It binds in-player keys for controlling the overlay, so start, stop, toggle, and skip-intro all work without leaving the player window.
|
||||
|
||||
**Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
|
||||
Most people never touch it. Any SubMiner-managed launch, whether from the app, the `subminer` launcher, or the Windows shortcut, injects the bundled plugin for that session, and nothing lands in mpv's global `scripts` directory. Keep reading if you launch mpv from some other tool and still want the in-player controls, or you want to script mpv against SubMiner.
|
||||
|
||||
The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `main.lua`, which loads `init.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
|
||||
The plugin is a modular Lua package under `plugin/subminer/`. `main.lua` is the entry point and loads `init.lua` plus its sibling modules. Earlier releases installed a single global `main.lua`; runtime loading replaced that.
|
||||
|
||||
## Runtime Loading
|
||||
## Runtime loading
|
||||
|
||||
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
|
||||
|
||||
@@ -67,7 +67,7 @@ The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMi
|
||||
|
||||
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
|
||||
|
||||
## Shared Shortcuts (Session Bindings)
|
||||
## Shared shortcuts (session bindings)
|
||||
|
||||
The `y-*` chords above are built into the plugin. Everything else you configure under [`shortcuts.*`](/shortcuts) - plus any custom [`keybindings`](/configuration) and the stats toggle/mark-watched keys - is **injected into mpv at runtime**, so the same shortcut works both inside mpv and in the SubMiner overlay. You do not edit any mpv config to enable them.
|
||||
|
||||
@@ -104,7 +104,7 @@ SubMiner:
|
||||
|
||||
Select an item by pressing its number.
|
||||
|
||||
## Binary Auto-Detection
|
||||
## Binary auto-detection
|
||||
|
||||
When `binary_path` is empty, the plugin searches platform-specific locations:
|
||||
|
||||
@@ -131,7 +131,7 @@ A PowerShell system lookup runs first (running SubMiner process, registry App Pa
|
||||
|
||||
On Windows the plugin also normalizes a Unix-style `socket_path` (`/tmp/subminer-socket`) to the named pipe `\\.\pipe\subminer-socket` at runtime.
|
||||
|
||||
## Backend Detection
|
||||
## Backend detection
|
||||
|
||||
When `backend=auto`, the plugin detects the window manager:
|
||||
|
||||
@@ -145,7 +145,7 @@ When `backend=auto`, the plugin detects the window manager:
|
||||
Native Wayland support is only available for Hyprland and Sway. If you use a different Wayland compositor, auto-detection will fall back to X11 - both mpv and SubMiner must be running under Xwayland, and `xdotool` and `xwininfo` must be installed.
|
||||
:::
|
||||
|
||||
## Script Messages
|
||||
## Script messages
|
||||
|
||||
The plugin can be controlled from other mpv scripts or the mpv command line using script messages:
|
||||
|
||||
@@ -189,7 +189,7 @@ For how the plugin's auto-start fits into the full launch sequence - including w
|
||||
- **MPV shutdown**: The plugin clears its hover/OSD/gate state on shutdown; the overlay app notices the closed IPC socket and shuts itself down.
|
||||
- **Texthooker**: When `texthooker_enabled=yes`, the plugin appends `--texthooker` to the overlay start command so the app starts the texthooker server alongside the overlay.
|
||||
|
||||
## Using with the `subminer` Wrapper
|
||||
## Using with the `subminer` wrapper
|
||||
|
||||
The `subminer` wrapper script handles mpv launch, socket setup, and overlay lifecycle automatically. You do not need the plugin if you always use the wrapper.
|
||||
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
// Configure an existing GGML model path or explicitly download a SubMiner-managed model.
|
||||
// Hot-reload: settings apply to the next generation or model download.
|
||||
// ==========================================
|
||||
"subtitleGeneration": {
|
||||
"whisperPath": "", // Optional path override for whisper.cpp. Leave empty to find whisper-cli on PATH.
|
||||
"modelPath": "", // Path to an existing multilingual whisper.cpp GGML model. Leave empty to use a SubMiner-managed model. A configured path always takes precedence.
|
||||
"managedModel": "small", // Multilingual whisper.cpp model to use when modelPath is empty. Download it explicitly from the generation modal or launcher. Values: tiny | tiny-q5_1 | tiny-q8_0 | base | base-q5_1 | base-q8_0 | small | small-q5_1 | small-q8_0 | medium | medium-q5_0 | medium-q8_0 | large-v1 | large-v2 | large-v2-q5_0 | large-v2-q8_0 | large-v3 | large-v3-q5_0 | large-v3-turbo | large-v3-turbo-q5_0 | large-v3-turbo-q8_0
|
||||
"threads": 4, // Positive integer CPU thread count for whisper.cpp Japanese transcription.
|
||||
"ffmpegPath": "", // Optional FFmpeg path override for audio extraction. Leave empty to find ffmpeg on PATH.
|
||||
"ffprobePath": "", // Optional FFprobe path override for audio tracks and timing. Leave empty to find ffprobe on PATH.
|
||||
"vadModelPath": "", // Path to a whisper.cpp Silero VAD model. Enables dialogue-focused generation from separate speech passages. Leave empty to transcribe the full audio, including songs.
|
||||
"vadPath": "" // Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments on PATH.
|
||||
}, // Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
|
||||
// ==========================================
|
||||
// Visible Overlay Auto-Start
|
||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||
@@ -206,6 +223,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
@@ -341,12 +359,6 @@
|
||||
"__playlist-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Alt+KeyA", // Key setting.
|
||||
"command": [
|
||||
"__anime-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||
"command": [
|
||||
@@ -529,7 +541,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -575,6 +587,7 @@
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -622,21 +635,6 @@
|
||||
} // Lapis kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
// Anime Browser
|
||||
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
|
||||
// Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause. Values: true | false
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
|
||||
+13
-11
@@ -1,4 +1,4 @@
|
||||
# Keyboard Shortcuts
|
||||
# Keyboard shortcuts
|
||||
|
||||
This page is the complete reference for every keystroke SubMiner responds to. If you are just getting started, focus on the **Mining Shortcuts** and **Overlay Controls** sections - those cover the day-to-day mining loop. The rest can wait until you need them.
|
||||
|
||||
@@ -10,7 +10,7 @@ A few terms used throughout:
|
||||
|
||||
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
|
||||
|
||||
## App-Wide Shortcuts
|
||||
## App-wide shortcuts
|
||||
|
||||
| Shortcut | Action | Scope | Configurable |
|
||||
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
|
||||
@@ -21,10 +21,12 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
||||
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
|
||||
:::
|
||||
|
||||
## Mining Shortcuts
|
||||
## Mining shortcuts
|
||||
|
||||
These work when the overlay window has focus.
|
||||
|
||||
When text is selected in the [subtitle sidebar](./subtitle-sidebar.md#selecting-and-copying-dialogue), `Ctrl/Cmd+C` copies that selection without timestamps, taking priority over the current-subtitle action. `Escape` clears the sidebar selection.
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | ----------------------------------------------- | --------------------------------------- |
|
||||
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
|
||||
@@ -37,7 +39,7 @@ These work when the overlay window has focus.
|
||||
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select the total number of subtitle lines to combine, ending at the current line and moving backward through the subtitle timeline. The current line counts toward the selected total. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
|
||||
|
||||
## Overlay Controls
|
||||
## Overlay controls
|
||||
|
||||
These control playback and subtitle display. They require overlay window focus.
|
||||
|
||||
@@ -49,7 +51,6 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `Ctrl+Alt+A` | Toggle Anime Browser inside the player |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
@@ -68,13 +69,13 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut toggles a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent, while active playback is shared.
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||
|
||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||
|
||||
## Subtitle & Feature Shortcuts
|
||||
## Subtitle and feature shortcuts
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
|
||||
@@ -83,6 +84,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl+Shift+G` | Open Japanese subtitle generation modal | `shortcuts.openSubtitleGeneration` |
|
||||
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
@@ -98,7 +100,7 @@ The stats toggle is handled inside the focused visible overlay window. It is con
|
||||
|
||||
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||
|
||||
## Controller Shortcuts
|
||||
## Controller shortcuts
|
||||
|
||||
These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration.
|
||||
|
||||
@@ -109,7 +111,7 @@ These overlay-local shortcuts open controller utilities for the Chrome Gamepad A
|
||||
|
||||
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
|
||||
|
||||
## MPV Plugin Chords
|
||||
## MPV plugin chords
|
||||
|
||||
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key (the overlay-side chord times out after 1 second; the mpv plugin uses native mpv key sequences).
|
||||
|
||||
@@ -129,14 +131,14 @@ The bare `v` plugin binding intentionally overrides mpv's native primary subtitl
|
||||
|
||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
||||
|
||||
## Drag-and-Drop
|
||||
## Drag-and-drop
|
||||
|
||||
| Gesture | Action |
|
||||
| ------------------------- | ------------------------------------------------ |
|
||||
| Drop file(s) onto overlay | Replace current mpv playlist with dropped files |
|
||||
| `Shift` + drop file(s) | Append all dropped files to current mpv playlist |
|
||||
|
||||
## Customizing Shortcuts
|
||||
## Customizing shortcuts
|
||||
|
||||
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+D"`. Use `null` to disable a shortcut.
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Subtitle Annotations
|
||||
# Subtitle annotations
|
||||
|
||||
SubMiner annotates subtitle tokens in real time as they appear in the overlay. Four annotation layers work together to surface useful context while you watch: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**.
|
||||
SubMiner annotates subtitle tokens as they appear in the overlay. There are four layers: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**.
|
||||
|
||||
All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently - you can enable any combination.
|
||||
All four are off by default and live under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne`. They are independent, so any combination works.
|
||||
|
||||
::: tip Tokenization
|
||||
SubMiner's primary tokenizer is Yomitan itself - subtitle text is tokenized based entirely on the dictionaries you have installed in Yomitan. Installing many large dictionaries can increase noise and slow down lookups, so be selective about which dictionaries you install and their priority order.
|
||||
Yomitan is the tokenizer, so the dictionaries you installed there decide where word boundaries fall. Piling on large dictionaries adds noise and slows lookups. Be picky about which ones you install and what order you rank them in.
|
||||
:::
|
||||
|
||||
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
|
||||
|
||||
Kanji vocabulary that MeCab labels `名詞/非自立`, such as `日` or `以外`, remains content for every annotation layer. The `非自立` exclusion only suppresses kana grammar nouns such as `こと` and `もの`.
|
||||
|
||||
## N+1 Word Highlighting
|
||||
## N+1 word highlighting
|
||||
|
||||
N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly.
|
||||
An N+1 sentence is one where you know every word but a single unknown. Those are the best mining targets, because the rest of the sentence gives you the context for free. SubMiner caches your known vocabulary from Anki and marks the lines that qualify.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -43,9 +43,9 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f
|
||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||
:::
|
||||
|
||||
## Known-Word Maturity Highlighting
|
||||
## Known-word maturity highlighting
|
||||
|
||||
Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned.
|
||||
Maturity highlighting tints each known token by the review state of its Anki cards instead of painting every known word the same color, so you can see how much of a line you actually have down. asbplayer does the same thing.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -81,7 +81,7 @@ bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt
|
||||
|
||||
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
|
||||
|
||||
## Character-Name Highlighting
|
||||
## Character-name highlighting
|
||||
|
||||
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
|
||||
|
||||
@@ -102,9 +102,9 @@ Character-name matches are built from the active merged SubMiner character dicti
|
||||
|
||||
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
||||
|
||||
## Frequency Highlighting
|
||||
## Frequency highlighting
|
||||
|
||||
Frequency highlighting colors tokens based on how common they are, using dictionary frequency rank data. This helps you spot high-value vocabulary at a glance. For each token, ranks from the installed Yomitan frequency dictionaries are consulted in priority order: the highest-priority dictionary that has the term wins, lower-priority dictionaries fill in terms it lacks, and occurrence-based dictionaries are skipped.
|
||||
Frequency highlighting colors tokens by how common the word is, so a rare word in an otherwise easy line stands out. Ranks come from your installed Yomitan frequency dictionaries, read in priority order. The highest-priority dictionary that has the term wins, lower-priority ones fill in terms it lacks, and occurrence-based dictionaries are skipped.
|
||||
|
||||
**Modes:**
|
||||
|
||||
@@ -137,9 +137,9 @@ Frequency highlighting skips tokens that look like non-lexical noise (kana redup
|
||||
Frequency, JLPT, and N+1 metadata are only shown for tokens that survive the subtitle-annotation noise filter. Standalone grammar tokens like `は`, `です`, and `この` are intentionally left unannotated even if a dictionary can assign them metadata.
|
||||
:::
|
||||
|
||||
## JLPT Tagging
|
||||
## JLPT tagging
|
||||
|
||||
JLPT tagging adds colored underlines to tokens based on their JLPT level (N1–N5), giving you an at-a-glance sense of difficulty distribution in each subtitle line.
|
||||
JLPT tagging underlines each token in a color for its JLPT level (N1–N5), so the difficulty spread of a line is visible without reading it closely.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -164,7 +164,7 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
|
||||
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
|
||||
| `subtitleStyle.jlptColors.N1`–`N5` | see above | Per-level underline colors |
|
||||
|
||||
## Runtime Toggles
|
||||
## Runtime toggles
|
||||
|
||||
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
|
||||
|
||||
@@ -177,9 +177,9 @@ These annotation layers can be toggled at runtime via the runtime options palett
|
||||
|
||||
(Character-name matching, `subtitleStyle.nameMatchEnabled`, is toggled through config or the Settings window, not the runtime palette.)
|
||||
|
||||
Toggles only apply to new subtitle lines after the change - the currently displayed line is not re-tokenized in place.
|
||||
A toggle takes effect on the next subtitle line. SubMiner does not re-tokenize the line already on screen.
|
||||
|
||||
## Rendering Priority
|
||||
## Rendering priority
|
||||
|
||||
When multiple annotations apply to the same token, the visual priority is:
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Japanese subtitle generation
|
||||
|
||||
Generate Japanese SRT subtitles from a local video's audio using [whisper.cpp](https://github.com/ggml-org/whisper.cpp). The launcher and overlay use the same local generation service. Audio stays on your computer. Model downloads require an internet connection; generation with an installed model does not.
|
||||
|
||||
## Setup
|
||||
|
||||
Install whisper.cpp's `whisper-cli` executable and FFmpeg, including `ffprobe`. SubMiner downloads models, not these executables. Leave `whisperPath`, `ffmpegPath`, and `ffprobePath` empty to find the executables on `PATH`. To use a specific installation, set a path override under **Settings → Integrations → Japanese Subtitle Generation**.
|
||||
|
||||
Choose one model source:
|
||||
|
||||
- Set `subtitleGeneration.modelPath` to an existing **multilingual whisper.cpp GGML `.bin` model**. Python Whisper checkpoints and English-only models are not suitable for Japanese transcription.
|
||||
- Leave that path empty and choose a model directly in the generation modal. Each option shows its download size; the selected model has speed and accuracy guidance. The modal offers **Download model** when that model is missing. Your choice lasts for the current SubMiner session, including closing and reopening the modal. Set `subtitleGeneration.managedModel` in Settings to change the default for future sessions.
|
||||
|
||||
Managed models are stored in `models/whisper/` beside your SubMiner configuration file. Downloads show progress, verify the expected file size and SHA256, and publish the model only after verification. Cancelling or failing a download removes its temporary files. A configured external path always takes precedence; an unreadable path displays an error instead of silently downloading another model.
|
||||
|
||||
See the [generated configuration example](/config.example.jsonc) for current defaults. Changes apply to the next operation.
|
||||
|
||||
## Prioritizing spoken dialogue
|
||||
|
||||
To focus on dialogue, check the optional **Focus on spoken dialogue** box in the generation modal. If the speech detection model is missing, click **Download speech detection model** to install it. This separate download uses the same progress, cancellation, and integrity checks as Whisper downloads. Checking the box never downloads automatically, and leaving it unchecked lets you generate without the Silero model.
|
||||
|
||||
You also need whisper.cpp's [speech segment detector](https://github.com/ggml-org/whisper.cpp/tree/master/examples/vad-speech-segments). SubMiner downloads the model, not this executable. The detector is found as `whisper-vad-speech-segments` on `PATH`. Builds from the upstream source may name it `vad-speech-segments`; set `vadPath` in **Settings → Integrations → Japanese Subtitle Generation** when needed.
|
||||
|
||||
The checkbox choice lasts for the current SubMiner session, including closing and reopening the modal. To make dialogue mode your default, set `vadModelPath` in Settings to a [Silero GGML VAD model](https://huggingface.co/ggml-org/whisper-vad/tree/main). The modal downloads `ggml-silero-v6.2.0.bin` into the same `models/whisper/` directory as managed Whisper models. An existing configured VAD path takes precedence and checks the box initially. Unchecking it temporarily disables dialogue mode without changing that path. Downloading the model alone does not enable dialogue mode.
|
||||
|
||||
With speech detection configured, SubMiner transcribes short speech passages separately and restores each passage's position on the original audio timeline. Detection retains brief utterances and includes extra audio around speech to reduce clipped syllables. If the detector returns a long passage, SubMiner looks for quiet pauses near chunk boundaries. Adjacent chunks overlap slightly to provide context when speech continues through a cut. Matching subtitle cues in that overlap are combined; repeated dialogue at separate times remains separate.
|
||||
|
||||
SubMiner resets transcription context between passages and limits subtitle cues to the audio supplied for each chunk. A line cannot stretch across an omitted music break. Progress reports completed batches of dialogue passages. These adjustments do not replace Whisper's timestamp estimates or guarantee that every spoken line is recognized.
|
||||
|
||||
This mode prioritizes spoken dialogue over songs and background sounds. It can miss quiet speech or speech mixed with loud music, and recognition errors are still possible. Uncheck **Focus on spoken dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
|
||||
|
||||
## Choosing a model
|
||||
|
||||
Start with **small** for a balance of Japanese recognition quality and CPU time. This is a general starting recommendation, not a benchmark for your hardware. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
|
||||
|
||||
The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages).
|
||||
|
||||
A configured external Model Path takes precedence and hides the managed model picker. Clear it in Settings to choose a managed model. Changing the picker never downloads automatically, and it cannot change the model during an active download or generation.
|
||||
|
||||
## From the overlay
|
||||
|
||||
1. Open a local video in mpv and select its Japanese audio track.
|
||||
2. Press **Ctrl+Shift+G** to open the standalone generation modal. When the subtitle sidebar has no subtitle lines loaded, it also offers a **Generate Japanese subtitles** button. Neither an open sidebar nor an existing subtitle track is required for the shortcut.
|
||||
3. Choose a model and download it if prompted, or configure your existing model path in Settings and click **Check again**.
|
||||
4. Optionally check **Focus on spoken dialogue** and click **Download speech detection model** if prompted.
|
||||
5. Click **Generate subtitles**.
|
||||
|
||||
The modal shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result.
|
||||
|
||||
**Escape** or **Close** closes the modal using the same focus and overlay restoration as other SubMiner modals. Change or disable its shortcut with `shortcuts.openSubtitleGeneration` in Settings. Ctrl+G remains assigned to field grouping.
|
||||
|
||||
SubMiner saves `<video>.ja.generated.srt` beside the media, adding a numeric suffix if that name already exists. It selects the generated Japanese subtitle track and resets the subtitle delay when mpv is still playing the same file. If playback changes, the subtitles remain saved and are not attached to the new video. The result includes the saved path even if mpv cannot load it.
|
||||
|
||||
## From the launcher
|
||||
|
||||
```bash
|
||||
subminer generate-subs episode.mkv --download-model
|
||||
subminer generate-subs episode.mkv --model-path /path/to/ggml-small.bin
|
||||
subminer generate-subs
|
||||
```
|
||||
|
||||
With no file argument, the command uses the current local mpv media and its selected audio track. With an explicit file, it prefers an audio stream tagged Japanese, otherwise the first audio stream. Use `--audio-stream` to choose an absolute FFmpeg stream index. `--output` specifies a new destination SRT; existing output files are never overwritten. See [launcher usage](/usage) for all flags. Ctrl+C cancels the operation.
|
||||
|
||||
## Timing and limitations
|
||||
|
||||
The SRT includes whisper.cpp's timestamps, adjusted for the audio stream's position on the media timeline and, when speech detection is configured, each passage's original start time. No alass step is required to load it. This version uses native Whisper timing; it does not run WhisperX or another forced aligner. Recognition can repeat or invent lines, and timing can be imperfect, especially with music or overlapping speech. Review generated text and audio boundaries when mining.
|
||||
|
||||
Generation supports local files and internal audio tracks. Remote URLs, subtitle translation, and transcription of a separately attached mpv audio track are not supported by the modal. Pass a separate local audio file to the launcher if needed. The destination directory needs writable space for subtitles; temporary storage needs enough space for the extracted mono audio.
|
||||
@@ -1,28 +1,39 @@
|
||||
# Subtitle Sidebar
|
||||
# Subtitle sidebar
|
||||
|
||||
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
|
||||
The subtitle sidebar puts the whole parsed cue list for the active subtitle file in a scrollable panel next to mpv. Scroll back through lines you already passed, look ahead at what is coming, and click any cue to seek straight to it. The overlay only ever shows the current line; the sidebar shows the rest.
|
||||
|
||||
The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if you want to turn it off.
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
When the sidebar has no subtitle lines loaded, the **Generate Japanese subtitles** button opens [local subtitle generation](/subtitle-generation). The button hides once subtitle lines are loaded and stays hidden between lines. Press **Ctrl+Shift+G** to open generation at any time.
|
||||
|
||||
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
|
||||
|
||||
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
||||
- Between subtitle lines, the sidebar follows playback to the next cue without jumping back to a cue at the start of the file.
|
||||
- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining.
|
||||
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
|
||||
- The sidebar and the overlay share one cue list, so a media change or subtitle source switch updates both at once.
|
||||
|
||||
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
|
||||
|
||||
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
|
||||
The sidebar only opens when a parsed cue list exists. Subtitle sources SubMiner cannot parse, such as embedded ASS tracks that mpv renders itself, leave it empty.
|
||||
|
||||
## Layout Modes
|
||||
## Selecting and copying dialogue
|
||||
|
||||
Drag across subtitle text to select an excerpt, including across multiple rows. Scroll to extend a selection through a longer conversation. `Ctrl/Cmd+C` or the **Copy** button copies the highlighted text in subtitle order, without timestamps. Partial first and last lines are preserved, with a blank line between subtitle cues.
|
||||
|
||||
Dragging to select does not seek playback. Playback-following auto-scroll stops while you drag or have a selection, so the excerpt stays in view. Press `Escape` to clear the selection. An ordinary click with no selection still seeks to that cue.
|
||||
|
||||
Selection survives playback updates and Yomitan popup dismissal. Changing media or subtitle sources, refreshing the cue list, or closing the sidebar clears it. Copying an excerpt does not require creating an Anki card.
|
||||
|
||||
## Layout modes
|
||||
|
||||
Two layout modes are available via `subtitleSidebar.layout`:
|
||||
|
||||
**`overlay`** (default) - The sidebar floats over mpv as a panel. It does not affect the player window size or position.
|
||||
|
||||
**`embedded`** - Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue.
|
||||
**`embedded`** - Reserves space on the right side of the player and shifts the video area over, giving you a split pane. Use this when you want the cue list up without it covering the video. Positioning depends on the compositor, so switch back to `overlay` if the geometry comes out wrong.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -77,7 +88,7 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr
|
||||
| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color |
|
||||
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
|
||||
|
||||
## Keyboard Shortcut
|
||||
## Keyboard shortcut
|
||||
|
||||
| Key | Action | Config key |
|
||||
| --- | ----------------------- | ------------------------------ |
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Troubleshooting
|
||||
|
||||
Common issues and how to resolve them. Most problems fall into one of a few buckets - the overlay shows but subtitles don't (see [MPV Connection](#mpv-connection)), cards aren't being created or come out empty (see [AnkiConnect](#ankiconnect)), or word lookups don't appear (see [Yomitan](#yomitan)). If an error message popped up on screen, search this page for the exact text - most headings below are quoted error strings.
|
||||
Almost everything that goes wrong lands in one of three places. The overlay shows but no subtitles arrive, which is [MPV Connection](#mpv-connection). Cards get created but come out empty, which is [AnkiConnect](#ankiconnect). Or hovering a word does nothing, which is [Yomitan](#yomitan).
|
||||
|
||||
## MPV Connection
|
||||
If you got an error message on screen, search this page for its exact text. Most headings below are quoted error strings.
|
||||
|
||||
## MPV connection
|
||||
|
||||
**Overlay starts but shows no subtitles**
|
||||
|
||||
SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the socket does not exist or the path does not match, the overlay will appear but subtitles will never arrive.
|
||||
|
||||
- Ensure mpv is running with `--input-ipc-server=/tmp/subminer-socket`.
|
||||
- Check that mpv is running with `--input-ipc-server=/tmp/subminer-socket`.
|
||||
- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpv.socketPath`).
|
||||
- The `subminer` wrapper script sets the socket automatically when it launches mpv. If you launch mpv yourself, the `--input-ipc-server` flag is required.
|
||||
|
||||
@@ -18,7 +20,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
|
||||
|
||||
**"Failed to parse MPV message"**
|
||||
|
||||
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
||||
A malformed JSON line arrived from the mpv socket. SubMiner drops the line and keeps going, so a stray one is harmless. A constant stream of them means something else is writing to the same socket path.
|
||||
|
||||
## Updates
|
||||
|
||||
@@ -85,7 +87,7 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
|
||||
|
||||
**Overlay appears but clicks pass through / cannot interact**
|
||||
|
||||
- Make sure you are hovering over subtitle text - the overlay only becomes interactive when the cursor is over a subtitle.
|
||||
- Hover directly over subtitle text. The overlay only takes pointer input while the cursor is over a subtitle.
|
||||
- On macOS/Windows: toggle the overlay off and back on (`Alt+Shift+O`) to re-enable pointer events.
|
||||
- On Linux: mouse event handling is unreliable in some Electron/compositor combinations. If clicks consistently fail, toggle the overlay off, click the underlying mpv window, then toggle it back on.
|
||||
|
||||
@@ -99,9 +101,9 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
|
||||
|
||||
SubMiner positions the overlay by tracking the mpv window. If tracking fails:
|
||||
|
||||
- Hyprland: Ensure `hyprctl` is available.
|
||||
- Sway: Ensure `swaymsg` is available.
|
||||
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
||||
- Hyprland: `hyprctl` must be on `PATH`.
|
||||
- Sway: `swaymsg` must be on `PATH`.
|
||||
- X11: `xdotool` and `xwininfo` must be installed.
|
||||
|
||||
If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset.
|
||||
|
||||
@@ -124,12 +126,12 @@ If you installed from the AppImage and see this error, the package may be incomp
|
||||
|
||||
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
|
||||
|
||||
- Verify Yomitan loaded successfully - check the terminal output for "Loaded Yomitan extension".
|
||||
- Look for "Loaded Yomitan extension" in the terminal output.
|
||||
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported.
|
||||
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
||||
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
|
||||
|
||||
## MeCab / Tokenization
|
||||
## MeCab / tokenization
|
||||
|
||||
**"MeCab not found on system"**
|
||||
|
||||
@@ -145,19 +147,19 @@ To install MeCab:
|
||||
|
||||
Japanese word boundaries depend on Yomitan parser output. If segmentation seems wrong:
|
||||
|
||||
- Verify Yomitan dictionaries are installed and active.
|
||||
- Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect.
|
||||
- Check that Yomitan dictionaries are installed and active.
|
||||
- Japanese text has no spaces, so the parser guesses word boundaries. It gets some of them wrong.
|
||||
|
||||
## Character Dictionary
|
||||
## Character dictionary
|
||||
|
||||
Character names from AniList are matched and highlighted in subtitles via the bundled Yomitan. See [Character Dictionary](/character-dictionary) for setup and the full troubleshooting list - the most common issues:
|
||||
|
||||
- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`, and that the current media resolved to an AniList entry (SubMiner needs a media ID to fetch characters). No AniList account or token is required - character data uses public GraphQL queries.
|
||||
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. Portraits also require AniList to return an image and the download to succeed during snapshot generation.
|
||||
- **Names not highlighting:** Check that `subtitleStyle.nameMatchEnabled` is `true` and that the current media resolved to an AniList entry, since SubMiner needs a media ID to fetch characters. No AniList account or token is needed; character data comes from public GraphQL queries.
|
||||
- **Inline portraits missing:** Check that `subtitleStyle.nameMatchImagesEnabled` is `true`. AniList also has to return an image, and the download has to succeed while the snapshot is generated.
|
||||
- **Wrong characters showing:** Open the in-app manager (`Ctrl/Cmd+D`) and use **Override** to pin the correct AniList match for the series.
|
||||
- **Feature unavailable:** If `yomitan.externalProfilePath` is set, SubMiner runs in read-only external-profile mode and its character-dictionary features are disabled.
|
||||
|
||||
## Media Generation
|
||||
## Media generation
|
||||
|
||||
**"FFmpeg not found"**
|
||||
|
||||
@@ -193,7 +195,7 @@ This warning refers to the OS-registered shortcut `Alt+Shift+Y` (Yomitan setting
|
||||
|
||||
Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use `Alt+Shift+O` (with the overlay or mpv focused) to toggle it and give it focus.
|
||||
|
||||
## Subtitle Timing
|
||||
## Subtitle timing
|
||||
|
||||
**"Subtitle timing not found; copy again while playing"**
|
||||
|
||||
@@ -205,72 +207,35 @@ This OSD message appears when you try to mine a sentence but SubMiner has no tim
|
||||
|
||||
Resume playback and wait for the next subtitle to appear, then try mining again.
|
||||
|
||||
## Subtitle Sync (Subsync)
|
||||
## Subtitle sync (subsync)
|
||||
|
||||
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
|
||||
|
||||
Subsync writes to the application log under the `subsync` scope, so the full command failure — exit code, stderr, resolved file paths — is recorded there as well as on the OSD.
|
||||
|
||||
**"Could not find alass" / "Configured alass executable not found"**
|
||||
**"Configured alass executable not found"**
|
||||
|
||||
Install alass or configure the path:
|
||||
|
||||
- **Homebrew**: `brew install alass`
|
||||
- **Arch Linux (AUR)**: `paru -S alass`
|
||||
- **Cargo**: `cargo install alass-cli`
|
||||
- Set the path: `subsync.alass_path` in your config.
|
||||
|
||||
Leaving the option empty searches `PATH` plus the usual install prefixes, and accepts either `alass` or `alass-cli`. Set the option explicitly when the binary lives somewhere else. The second message means the configured path itself does not exist — SubMiner never silently substitutes a different binary for one you named.
|
||||
|
||||
**"Could not find ffsubsync" / "Configured ffsubsync executable not found"**
|
||||
**"Configured ffsubsync executable not found"**
|
||||
|
||||
Install ffsubsync or configure the path:
|
||||
|
||||
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
|
||||
- **pip**: `pip install ffsubsync`
|
||||
- Must be discoverable, or configured via `subsync.ffsubsync_path` in your config.
|
||||
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config.
|
||||
|
||||
**"alass synchronization failed" / "ffsubsync synchronization failed"**
|
||||
|
||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||
|
||||
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||
- Select a reference. alass needs either a second subtitle track or the local video file, and it cannot be the track being retimed.
|
||||
- Check that `ffmpeg` is available, since it extracts the internal subtitle track.
|
||||
- Try running the sync tool manually to see detailed error output.
|
||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||
|
||||
**Syncing subtitles on a stream (Aniyomi extensions, Jellyfin)**
|
||||
|
||||
Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, reusing mpv's own request headers, so they work as either the sync target or the alass reference. Downloading a Japanese track from Jimaku or TsukiHime while a stream is playing also works — it becomes the primary track and therefore the sync target.
|
||||
|
||||
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
|
||||
|
||||
Streams usually serve WebVTT, which alass cannot parse — it picks its parser from the file extension and treats a `.vtt` file as a video, failing with "no audio stream in file". SubMiner rewrites both the target and the reference as SRT for alass, so this is handled automatically; the retimed track mpv loads is that SRT.
|
||||
|
||||
## Anime Browser
|
||||
|
||||
See the [anime browser guide](/anime-browser) for how sources and the bridge work.
|
||||
|
||||
**Nothing to search**
|
||||
|
||||
SubMiner ships no extension repositories and bundles no sources. Until you add a repository index URL in the **Extensions** tab (or drop Aniyomi `.apk` files into the extensions directory) the browser has nothing to query.
|
||||
|
||||
**"No pinned checksum for …"**
|
||||
|
||||
The bridge bundle is verified against a SHA-256 that a maintainer has checked by hand. Bundles exist for macOS (arm64, x64), Linux (x64), and Windows (x64), but only macOS arm64 and Linux x64 are pinned so far; the rest stop with this message rather than running an unverified download. Other platforms are unsupported outright.
|
||||
|
||||
**A source returns nothing, or asks for a login**
|
||||
|
||||
Most extensions need configuration first - a server address, credentials, a preferred quality. Open the **Source settings** tab and fill them in; each save is handed straight back to the extension. Sources that need an Android WebView (typically for Cloudflare challenges) cannot work here, because the bridge has none.
|
||||
|
||||
**"Playback failed" with an mpv error**
|
||||
|
||||
"Playing" is only reported once mpv actually configures a video output, so this is a real failure rather than a silent one: a dead host, an expired stream URL, or an undecodable stream. Resolve the episode again; if it keeps failing, try another source or quality entry.
|
||||
|
||||
**The browser starts failing every request**
|
||||
|
||||
A bridge that dies (killed, crashed, stopped mid-operation) is named in the status bar and restarted on the next request. Anything already playing ends with it, since stream URLs point at its loopback proxy.
|
||||
|
||||
## TsukiHime
|
||||
|
||||
**"xz binary not found"**
|
||||
@@ -291,23 +256,23 @@ Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime
|
||||
|
||||
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
||||
|
||||
## Logging and App Mode
|
||||
## Logging and app mode
|
||||
|
||||
- Default log output is `warn`.
|
||||
- Use `--log-level` for more/less output.
|
||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
||||
|
||||
## Performance and Resource Impact
|
||||
## Performance and resource impact
|
||||
|
||||
### At a glance
|
||||
### Where the cost comes from
|
||||
|
||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
||||
- Common spikes come from:
|
||||
- first subtitle parse/tokenization bursts
|
||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||
Idle playback with the overlay up is cheap. The spikes come from:
|
||||
|
||||
- first subtitle parse/tokenization bursts
|
||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||
|
||||
### If playback feels sluggish
|
||||
|
||||
@@ -322,19 +287,16 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
||||
2. Reduce rendering pressure:
|
||||
|
||||
- lower `subtitleStyle.css["font-size"]`
|
||||
- keep overlay complexity minimal during heavy CPU periods
|
||||
|
||||
3. Reduce media overhead:
|
||||
|
||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
||||
- keep `ankiConnect.media.imageType` set to `static`, since animated AVIF encoding is the most expensive path
|
||||
- lower `ankiConnect.media.imageQuality`
|
||||
- reduce `ankiConnect.media.maxMediaDuration`
|
||||
|
||||
4. Lower integration cost:
|
||||
|
||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
||||
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
||||
- set `immersionTracking.enabled: false` to stop session logging and its database writes
|
||||
|
||||
### Practical low-impact profile
|
||||
|
||||
@@ -357,9 +319,6 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
||||
"imageType": "static",
|
||||
"imageQuality": 80,
|
||||
"maxMediaDuration": 12
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"immersionTracking": {
|
||||
@@ -375,12 +334,12 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
||||
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
||||
|
||||
## Platform-Specific
|
||||
## Platform-specific
|
||||
|
||||
### Linux
|
||||
|
||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
|
||||
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support covers Hyprland and Sway only. Window tracking shells out to `hyprctl` or `swaymsg`; if neither is on `PATH`, tracking fails silently. Other Wayland compositors such as KDE Plasma and GNOME have no native backend - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-and-other-wayland-compositors)).
|
||||
- **X11 / Xwayland**: Needs `xdotool`, `xprop`, and `xwininfo`. Without them the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
||||
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
||||
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
||||
|
||||
@@ -438,7 +397,7 @@ SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry
|
||||
|
||||
For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
|
||||
|
||||
### KDE Plasma & other Wayland compositors
|
||||
### KDE Plasma and other Wayland compositors
|
||||
|
||||
On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and others), the overlay can only stay above mpv when both processes run under **XWayland** - the Wayland protocol forbids clients from controlling window stacking, so the overlay's "always on top" becomes a no-op on a native Wayland surface.
|
||||
|
||||
@@ -460,7 +419,7 @@ Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner use
|
||||
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
|
||||
|
||||
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
|
||||
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
|
||||
- Force XWayland in your own mpv command, for example `mpv --gpu-context=x11vk,x11egl,x11 <file>`. Launching with `WAYLAND_DISPLAY= mpv <file>` works too, as does setting `gpu-context=x11vk` (Vulkan) or `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
|
||||
|
||||
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
|
||||
|
||||
@@ -473,7 +432,7 @@ SubMiner can only detect focus for X11/Xwayland windows in this mode. If a nativ
|
||||
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
|
||||
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
|
||||
|
||||
## See Also
|
||||
## See also
|
||||
|
||||
Feature-specific issues are covered in each feature's own page:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TsukiHime Integration
|
||||
# TsukiHime integration
|
||||
|
||||
[TsukiHime](https://tsukihime.org) tracks anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the TsukiHime API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
|
||||
[TsukiHime](https://tsukihime.org) indexes anime torrent releases and pulls every attachment out of the release files, embedded subtitle tracks included, then hosts them for direct download. SubMiner talks to the TsukiHime API, so you can grab subtitles for the episode you are watching from the overlay without a torrent client. The download is decompressed, saved next to the video, and loaded into mpv straight away.
|
||||
|
||||
This is the multi-language companion to the [Jimaku integration](/jimaku-integration). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
|
||||
|
||||
@@ -12,21 +12,21 @@ TsukiHime replaces [Animetosho](https://animetosho.org), which stops processing
|
||||
Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is the `xz` binary on your `PATH` - TsukiHime serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter both the release list and the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Each tab lists only the releases whose reported subtitle languages include the tab's language, so the Japanese tab hides the many releases that ship English subtitles only. Releases and tracks with no language tag stay visible on the secondary tab. If nothing on the active tab qualifies, the status line says so and points at the other tab.
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately. Episodes launched from the [anime browser](/anime-browser) fill all three fields from the source's own listing instead of from a filename.
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <season> <episode>`. Season 1 is left out on purpose: releases of a first season almost never carry `S01` in their name, so including it would match nothing. Later seasons are included, which is what makes their releases findable at all. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
|
||||
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
|
||||
|
||||
Because releases on TsukiHime are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
|
||||
TsukiHime's releases are the same files that circulate as torrents. Pick the release matching your local file, same group and same version, and the timing lines up exactly with no resync. For a raw or a different group's encode, take any release of the episode and fix the offset with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`).
|
||||
|
||||
### Modal Keyboard Shortcuts
|
||||
### Modal keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ---------------------------- | ------------------------------- |
|
||||
@@ -38,7 +38,7 @@ Because releases on TsukiHime are the same files circulating as torrents, pickin
|
||||
|
||||
## Configuration
|
||||
|
||||
The integration works out of the box. An optional `tsukihime` section in `config.jsonc` tunes it:
|
||||
There is nothing to configure to get started. An optional `tsukihime` section in `config.jsonc` tunes it:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -66,7 +66,7 @@ The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
Existing Animetosho configuration remains compatible. SubMiner treats the old `animetosho` section and `shortcuts.openAnimetosho` setting as deprecated aliases. When old and current names are both present, `tsukihime` and `shortcuts.openTsukihime` take precedence.
|
||||
|
||||
## Other Ways to Open It
|
||||
## Other ways to open it
|
||||
|
||||
- CLI: `subminer --open-tsukihime`
|
||||
- Keybinding command: bind any key to `["__tsukihime-open"]` in the `keybindings` array
|
||||
@@ -76,6 +76,7 @@ The previous `--open-animetosho` flag and `__animetosho-open` keybinding command
|
||||
## Troubleshooting
|
||||
|
||||
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
|
||||
- **"No releases with Japanese subtitles"** - none of the search results carry a Japanese track. Most releases only ship English subtitles; try another search, or use the [Jimaku integration](/jimaku-integration) for Japanese subtitles.
|
||||
- **"Batch releases are not supported"** - TsukiHime only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
|
||||
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
|
||||
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
|
||||
|
||||
+63
-30
@@ -1,6 +1,6 @@
|
||||
# Usage
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
Play a video with SubMiner:
|
||||
|
||||
@@ -10,7 +10,7 @@ subminer video.mkv
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup - double-click it, or drag a video file onto it.
|
||||
|
||||
That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically.
|
||||
That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socket, and brings up the overlay.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
||||
@@ -36,17 +36,17 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
|
||||
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
When you launch SubMiner, it wires up mpv and the overlay for you:
|
||||
Launching SubMiner wires up mpv and the overlay for you:
|
||||
|
||||
1. SubMiner starts the overlay app in the background
|
||||
2. mpv runs with an **IPC socket** at `/tmp/subminer-socket` - a small local channel two programs use to talk to each other, so the overlay can ask mpv what subtitle is on screen right now
|
||||
3. The overlay connects and subscribes to subtitle changes
|
||||
|
||||
From there, subtitles render as interactive, hoverable word spans and you mine cards directly from the overlay. For the overlay anatomy and the full mining loop - word lookup, card creation, annotations - see [Mining Workflow](/mining-workflow).
|
||||
Subtitles then render as hoverable word spans, and you mine cards straight from the overlay. [Mining Workflow](/mining-workflow) covers the overlay layout, word lookup, card creation, and annotations.
|
||||
|
||||
### Ways to Launch
|
||||
### Ways to launch
|
||||
|
||||
| Approach | Use when | How |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
@@ -54,11 +54,11 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
||||
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
||||
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
||||
|
||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||
The mpv plugin is always available, because SubMiner bundles it and injects it at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||
|
||||
## Commands
|
||||
|
||||
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
|
||||
These are the ones you will use day to day. [Launcher Script](/launcher-script#subcommands) has every subcommand and flag.
|
||||
|
||||
```bash
|
||||
subminer video.mkv # Play a specific file
|
||||
@@ -70,7 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer stats # Open the immersion stats dashboard
|
||||
subminer doctor # Check dependencies, config, and the mpv socket
|
||||
subminer settings # Open the SubMiner settings window
|
||||
subminer anime # Open the anime browser window
|
||||
subminer generate-subs video.mkv # Generate Japanese subtitles from local audio
|
||||
subminer app --setup # Re-open first-run setup
|
||||
subminer -u # Check for updates
|
||||
```
|
||||
@@ -82,6 +82,40 @@ Two flags are worth knowing early:
|
||||
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
|
||||
- `--log-level debug` turns on verbose logging when something is not working.
|
||||
|
||||
### Generate Japanese subtitles locally
|
||||
|
||||
`generate-subs` transcribes local audio with whisper.cpp, saves a timed Japanese SRT file,
|
||||
and loads it into mpv if that same media file is still playing, clearing the previous subtitle
|
||||
delay. It also works with no running
|
||||
SubMiner app or mpv instance when you provide a file path. Omit the path to use the current
|
||||
mpv file and selected audio track.
|
||||
|
||||
```bash
|
||||
subminer generate-subs video.mkv --download-model
|
||||
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
|
||||
subminer generate-subs --model medium --download-model
|
||||
subminer generate-subs video.mkv --audio-stream 2 --output ~/Subs/video.ja.srt
|
||||
```
|
||||
|
||||
Install `whisper-cli` from whisper.cpp, `ffmpeg`, and `ffprobe`, or configure their paths in
|
||||
`subtitleGeneration.whisperPath`, `subtitleGeneration.ffmpegPath`, and `subtitleGeneration.ffprobePath`.
|
||||
Set `subtitleGeneration.modelPath` in settings to reuse an existing whisper.cpp model.
|
||||
With no external path, SubMiner uses `subtitleGeneration.managedModel` and stores downloaded
|
||||
models under `models/whisper` beside its config file. `--model` selects an official multilingual model, including available quantized variants, for
|
||||
this invocation and overrides a configured external model path. Run `subminer generate-subs --help`
|
||||
for accepted names. See [model selection](/subtitle-generation#choosing-a-model) for accuracy and speed guidance.
|
||||
|
||||
Downloads only happen when you pass `--download-model` or choose the download action in the
|
||||
generation modal. The launcher reports each stage and percentages when available. Press Ctrl+C
|
||||
to cancel. `--audio-stream` takes an absolute ffprobe stream index. When you provide a file
|
||||
path without that flag, generation uses a Japanese audio track when tagged, falling back
|
||||
to the first audio track. With no file path, mpv must have an identifiable selected audio
|
||||
track, or you must provide `--audio-stream`.
|
||||
|
||||
Generated files include Whisper's native timing. Speech recognition can make mistakes,
|
||||
especially over music or overlapping dialogue, so check the wording before mining. Existing
|
||||
output files are preserved. See [configuration](/configuration) for the generation settings.
|
||||
|
||||
<details>
|
||||
<summary><b>Less common launcher commands</b></summary>
|
||||
|
||||
@@ -136,7 +170,6 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
||||
SubMiner.AppImage --anime # Open the anime browser window
|
||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
||||
@@ -153,7 +186,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
||||
|
||||
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
||||
|
||||
### Logging and App Mode
|
||||
### Logging and app mode
|
||||
|
||||
- `--log-level` controls logger verbosity.
|
||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||
@@ -167,7 +200,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
|
||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug` (or `SubMiner.exe --start --dev --log-level debug` on Windows).
|
||||
- `--playback-feedback <text>` (also `--playback-feedback=<text>`) sends a non-empty text string through the playback-feedback route used for recording/playback prompts. For example: `SubMiner.AppImage --playback-feedback "your feedback"`.
|
||||
|
||||
### Windows mpv Shortcut
|
||||
### Windows mpv shortcut
|
||||
|
||||
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
|
||||
|
||||
@@ -187,7 +220,7 @@ You can use it three ways:
|
||||
|
||||
This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blank to auto-discover from `PATH`, or set it to the full `mpv.exe` path if mpv is installed elsewhere. `SUBMINER_MPV_PATH` is still honored as a fallback.
|
||||
|
||||
### Launcher Subcommands
|
||||
### Launcher subcommands
|
||||
|
||||
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
|
||||
|
||||
@@ -195,9 +228,9 @@ Every subcommand has its own help page, for example `subminer jellyfin -h`. See
|
||||
|
||||
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||
|
||||
### First-Run Setup
|
||||
### First-run setup
|
||||
|
||||
Setup popup appears on first launch, or when setup has not been completed.
|
||||
The setup window opens on first launch and on any later launch where setup never finished.
|
||||
|
||||
You can also open it manually:
|
||||
|
||||
@@ -211,7 +244,7 @@ Setup flow:
|
||||
- config file: create the default config directory and prefer `config.jsonc`
|
||||
- legacy plugin cleanup: remove detected older global SubMiner mpv plugin files if present (the bundled plugin is injected at runtime automatically)
|
||||
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
||||
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
||||
- dictionary check: confirm at least one bundled Yomitan dictionary is present, unless an external Yomitan profile is configured
|
||||
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
||||
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
||||
- refresh: re-check dictionary state without restarting
|
||||
@@ -227,7 +260,7 @@ AniList character dictionary auto-sync (optional):
|
||||
Use subcommands for Jellyfin workflows (`subminer jellyfin ...`).
|
||||
Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
|
||||
|
||||
### MPV Profile Example (mpv.conf)
|
||||
### MPV profile example (mpv.conf)
|
||||
|
||||
`subminer` passes the following MPV options directly on launch by default:
|
||||
|
||||
@@ -266,13 +299,13 @@ secondary-sub-visibility=no
|
||||
|
||||
### Yomitan setup
|
||||
|
||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
||||
SubMiner bundles its own Yomitan extension for overlay lookups. It is a separate install from any Yomitan you run in a browser, with its own dictionaries and settings.
|
||||
|
||||
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yomitan` or `SubMiner.AppImage --yomitan`) and import at least one dictionary in the bundled Yomitan instance.
|
||||
|
||||
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
||||
If you also use Yomitan in a browser, set that profile up separately. It inherits nothing from the bundled instance.
|
||||
|
||||
### YouTube Playback
|
||||
### YouTube playback
|
||||
|
||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
|
||||
For YouTube playback, SubMiner resolves subtitle selection during startup while mpv is paused: it auto-selects the default primary subtitle track plus a best-effort secondary track, then resumes when primary subtitles are ready.
|
||||
@@ -290,7 +323,7 @@ Notes:
|
||||
|
||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||
|
||||
## Live Config Reload
|
||||
## Live config reload
|
||||
|
||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
||||
|
||||
@@ -307,16 +340,16 @@ Live-updated settings include:
|
||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
||||
- `stats.toggleKey`, `stats.markWatchedKey`
|
||||
- `youtube.primarySubLanguages`
|
||||
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
||||
- most `ankiConnect.*` settings
|
||||
|
||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
||||
|
||||
## Controller Support
|
||||
## Controller support
|
||||
|
||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||
SubMiner reads gamepads through the Chrome Gamepad API, so you can mine from the couch. The controller drives the overlay while keyboard-only mode is on.
|
||||
|
||||
### Getting Started
|
||||
### Getting started
|
||||
|
||||
1. Connect a controller before or after launching SubMiner.
|
||||
2. Set `controller.enabled` to `true` in your config.
|
||||
@@ -328,7 +361,7 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
||||
|
||||
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
||||
|
||||
### Default Button Mapping
|
||||
### Default button mapping
|
||||
|
||||
| Button | Action |
|
||||
| ----------------------- | --------------------------------------- |
|
||||
@@ -342,9 +375,9 @@ By default SubMiner uses the first connected controller after controller support
|
||||
| `Select` / `Minus` | Quit mpv |
|
||||
| `L2` / `R2` | Unbound (available for custom bindings) |
|
||||
|
||||
Note: the default quit binding uses gamepad button index 6. Pads that follow the W3C standard gamepad layout report L2 as index 6 (Select is index 8), so on those controllers the quit action may fire on L2 instead - use `Alt+C` learn mode to remap it for your pad.
|
||||
The default quit binding uses gamepad button index 6. Pads that follow the W3C standard layout report L2 as index 6 and Select as index 8, so on those controllers quit fires on L2 instead. Remap it with `Alt+C` learn mode.
|
||||
|
||||
### Analog Controls
|
||||
### Analog controls
|
||||
|
||||
| Input | Action |
|
||||
| --------------------- | --------------------------------------------- |
|
||||
@@ -353,7 +386,7 @@ Note: the default quit binding uses gamepad button index 6. Pads that follow the
|
||||
| Right stick vertical | Jump through Yomitan popup |
|
||||
| D-pad | Fallback for stick navigation when configured |
|
||||
|
||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
||||
Learn mode ignores inputs you are already holding and waits for the next fresh press or axis push, so opening the modal mid-input does not capture whatever your thumb was on.
|
||||
|
||||
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration - Controller Support](/configuration#controller-support) for the full options.
|
||||
|
||||
@@ -380,7 +413,7 @@ The changelog modal (tray > `View Changelog`) works the same way: it renders ove
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||
|
||||
### Drag-and-Drop
|
||||
### Drag-and-drop
|
||||
|
||||
- Drop video files onto the overlay to replace current playback.
|
||||
- Hold `Shift` while dropping to append to the playlist instead.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# WebSocket / Texthooker API & Integration
|
||||
# WebSocket and texthooker API
|
||||
|
||||
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
|
||||
This page is for people wiring SubMiner's live subtitle stream into their own tools: a browser tab, an automation script, another mpv plugin. If you only want subtitles in a browser tab for Yomitan, jump to [Texthooker Integration Guide](#texthooker-integration-guide). Everything else here is reference for building a client.
|
||||
|
||||
A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
|
||||
|
||||
SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows:
|
||||
SubMiner opens four local integration points:
|
||||
|
||||
- **Subtitle WebSocket** at `ws://127.0.0.1:6677` by default for plain subtitle pushes.
|
||||
- **Annotation WebSocket** at `ws://127.0.0.1:6678` by default for token-aware clients.
|
||||
- **Texthooker HTTP UI** at `http://127.0.0.1:5174` by default for browser-based subtitle consumption.
|
||||
- **mpv plugin script messages** for in-player automation and extension.
|
||||
|
||||
This page documents those integration points and shows how to build custom consumers around them.
|
||||
The rest of this page documents each one and shows how to build a consumer for it.
|
||||
|
||||
## Quick Reference
|
||||
## Quick reference
|
||||
|
||||
| Surface | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
@@ -22,7 +22,7 @@ This page documents those integration points and shows how to build custom consu
|
||||
| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
|
||||
| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
|
||||
|
||||
## Enable and Configure the Services
|
||||
## Enable and configure the services
|
||||
|
||||
SubMiner's integration ports are configured in `config.jsonc`. All three services are **off by default** - the block below shows the values to set to turn them on.
|
||||
|
||||
@@ -52,9 +52,9 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service
|
||||
|
||||
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime - there is no plugin config file to edit.
|
||||
|
||||
## Developer API Documentation
|
||||
## Developer API documentation
|
||||
|
||||
### 1. Subtitle WebSocket
|
||||
### 1. subtitle WebSocket
|
||||
|
||||
Use the basic subtitle websocket when you only need the current subtitle line as plain text.
|
||||
|
||||
@@ -86,7 +86,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
|
||||
| `sentence` | string | Plain subtitle text with line breaks represented as `<br>`. No annotation spans or attributes. |
|
||||
| `tokens` | array | Always empty on the basic subtitle websocket. |
|
||||
|
||||
### 2. Annotation WebSocket
|
||||
### 2. annotation WebSocket
|
||||
|
||||
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
|
||||
|
||||
@@ -167,7 +167,7 @@ SubMiner also adds tooltip-friendly data attributes when available:
|
||||
|
||||
If you need a fully custom UI, ignore `sentence` and render from `tokens` instead.
|
||||
|
||||
## Texthooker Integration Guide
|
||||
## Texthooker integration guide
|
||||
|
||||
### When to use the bundled texthooker page
|
||||
|
||||
@@ -221,7 +221,7 @@ Here is a minimal browser client for the annotation stream:
|
||||
</script>
|
||||
```
|
||||
|
||||
### Build a custom Node client
|
||||
### Build a custom node client
|
||||
|
||||
```js
|
||||
import WebSocket from 'ws';
|
||||
@@ -245,7 +245,7 @@ ws.on('message', (raw) => {
|
||||
- Reconnect on disconnect; SubMiner does not manage client reconnects for you.
|
||||
- Prefer `payload.text` for logging/automation and `payload.sentence` or `payload.tokens` for UI rendering.
|
||||
|
||||
## Plugin Development
|
||||
## Plugin development
|
||||
|
||||
SubMiner does **not** currently expose a general-purpose third-party plugin SDK inside the app itself. Today, the supported extension surfaces are:
|
||||
|
||||
@@ -309,7 +309,7 @@ Examples:
|
||||
- local vocabulary capture helper that writes interesting lines to a file
|
||||
- bridge service that forwards websocket events into your own workflow engine
|
||||
|
||||
## Webhook Examples
|
||||
## Webhook examples
|
||||
|
||||
SubMiner does **not** currently send outbound webhooks by itself. The supported pattern is to consume the websocket locally and relay events into another system.
|
||||
|
||||
@@ -342,7 +342,6 @@ ws.on('message', async (raw) => {
|
||||
- **n8n / Make / Zapier relay:** send each subtitle line into an automation workflow for logging, translation, or summarization.
|
||||
- **Discord / Slack notifier:** post only lines that contain unknown words or N+1 targets.
|
||||
- **Obsidian / Markdown capture:** append subtitle lines plus token metadata to a daily immersion note.
|
||||
- **Local LLM pipeline:** trigger a glossary, translation, or sentence-mining workflow whenever a new line arrives.
|
||||
|
||||
### Filtering example: only forward N+1 lines
|
||||
|
||||
@@ -365,7 +364,7 @@ ws.on('message', async (raw) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Recommended Integration Combinations
|
||||
## Recommended integration combinations
|
||||
|
||||
- **Browser Yomitan client:** `texthooker` + `annotationWebsocket`
|
||||
- **Custom dashboard:** `annotationWebsocket` only
|
||||
@@ -373,7 +372,7 @@ ws.on('message', async (raw) => {
|
||||
- **mpv-side automation:** mpv plugin script messages + optional websocket relay
|
||||
- **Webhook-style workflows:** `annotationWebsocket` + your own local relay service
|
||||
|
||||
## Related Pages
|
||||
## Related pages
|
||||
|
||||
- [Configuration](/configuration#websocket-server)
|
||||
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# YouTube Integration
|
||||
# YouTube integration
|
||||
|
||||
SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you the same sentence-mining overlay experience as local video files. It probes available subtitle tracks via `yt-dlp`, selects the best primary and secondary tracks, downloads them, and loads them into mpv before playback resumes.
|
||||
Play a YouTube URL and SubMiner loads Japanese subtitles for it, so mining works the same as it does on a local file. It probes the available tracks with `yt-dlp`, picks a primary and a secondary, downloads both, and loads them into mpv before playback resumes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** must be installed and on your `PATH`. yt-dlp is a free command-line tool that reads YouTube video and subtitle info; SubMiner calls it behind the scenes. (`PATH` is the list of folders your system searches for programs - most installers add yt-dlp to it automatically. If yours did not, set `SUBMINER_YTDLP_BIN` to the full path of the yt-dlp binary.)
|
||||
- mpv with `--input-ipc-server` configured (handled automatically when you launch playback through the `subminer` launcher - no manual setup needed).
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||
|
||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||
5. **Load** --- Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block.
|
||||
1. **Probe** - `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||
2. **Discover** - Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||
3. **Select** - SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||
4. **Download** - Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||
5. **Load** - Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block.
|
||||
|
||||
## Pipeline Diagram
|
||||
## Pipeline diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -42,8 +42,8 @@ flowchart TD
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
D -- startup --> E
|
||||
D -- user request --> F
|
||||
D - startup --> E
|
||||
D - user request --> F
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H
|
||||
@@ -52,7 +52,7 @@ flowchart TD
|
||||
K --> L
|
||||
```
|
||||
|
||||
## Auto-Load Flow
|
||||
## Auto-load flow
|
||||
|
||||
On startup with a YouTube URL:
|
||||
|
||||
@@ -66,7 +66,7 @@ On startup with a YouTube URL:
|
||||
6. Missing tracks are downloaded to a temp directory and loaded via `sub-add`.
|
||||
7. Playback unpauses once the primary subtitle is ready.
|
||||
|
||||
## Manual Subtitle Picker
|
||||
## Manual subtitle picker
|
||||
|
||||
Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay. This lets you:
|
||||
|
||||
@@ -80,27 +80,27 @@ card to a success notification after the selected tracks load.
|
||||
|
||||
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||
|
||||
## Subtitle Format Handling
|
||||
## Subtitle format handling
|
||||
|
||||
SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
| Format | Handling |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML - converted to VTT automatically |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
## Card media cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`; set `0` for unlimited) and use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
## Configuration Reference
|
||||
## Configuration reference
|
||||
|
||||
### Primary Subtitle Languages
|
||||
### Primary subtitle languages
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -114,11 +114,11 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma
|
||||
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. |
|
||||
|
||||
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked.
|
||||
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track. `primarySubLanguages` does not change which YouTube track is auto-picked.
|
||||
|
||||
### Secondary Subtitle Languages
|
||||
### Secondary subtitle languages
|
||||
|
||||
YouTube secondary selection is fixed: SubMiner always tries an English track (manual over auto) and loads it when found. The shared `secondarySub` config does not change YouTube track selection — `secondarySubLanguages` and `autoLoadSecondarySub` apply only to local/Jellyfin sidecar selection — but `defaultMode` still controls how the loaded secondary bar is displayed:
|
||||
YouTube secondary selection is fixed: SubMiner always tries an English track (manual over auto) and loads it when found. The shared `secondarySub` config does not change YouTube track selection. `secondarySubLanguages` and `autoLoadSecondarySub` apply only to local and Jellyfin sidecar selection. `defaultMode` still controls how the loaded secondary bar is displayed:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -138,10 +138,10 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma
|
||||
|
||||
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
|
||||
|
||||
## Limitations and Troubleshooting
|
||||
## Limitations and troubleshooting
|
||||
|
||||
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
|
||||
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
|
||||
- **yt-dlp not found**: Install `yt-dlp` and put it on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
|
||||
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
|
||||
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
|
||||
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
|
||||
@@ -149,10 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
|
||||
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
|
||||
- **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface.
|
||||
|
||||
## Related Pages
|
||||
## Related pages
|
||||
|
||||
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
||||
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
||||
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
|
||||
- [Usage - YouTube Playback](/usage#youtube-playback)
|
||||
- [Configuration - YouTube Playback Settings](/configuration#youtube-playback-settings)
|
||||
- [Configuration - Secondary Subtitles](/configuration#secondary-subtitles)
|
||||
- [Keyboard Shortcuts](/shortcuts)
|
||||
- [Jellyfin Integration](/jellyfin-integration)
|
||||
|
||||
@@ -64,6 +64,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
|
||||
```typescript
|
||||
interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
@@ -76,6 +77,7 @@ interface SubtitleCue {
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas.
|
||||
|
||||
@@ -165,6 +167,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||
### Dependency Analysis
|
||||
|
||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||
|
||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||
@@ -181,18 +184,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
||||
|
||||
// Single pass: known word + frequency filtering + JLPT computed together
|
||||
const annotated = tokens.map((token) => {
|
||||
const isKnown = nPlusOneEnabled
|
||||
? token.isKnown || computeIsKnown(token, deps)
|
||||
: false;
|
||||
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||
|
||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||
const frequencyRank = frequencyEnabled
|
||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled
|
||||
? computeJlptLevel(token, deps.getJlptLevel)
|
||||
: undefined;
|
||||
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||
|
||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||
});
|
||||
@@ -233,6 +232,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
||||
### Current Behavior
|
||||
|
||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||
|
||||
1. Clears DOM with `innerHTML = ''`
|
||||
2. Creates a `DocumentFragment`
|
||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||
@@ -268,27 +268,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
||||
|
||||
## Combined Impact Summary
|
||||
|
||||
| Scenario | Before | After | Improvement |
|
||||
|----------|--------|-------|-------------|
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
| Scenario | Before | After | Improvement |
|
||||
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/core/services/subtitle-prefetch.ts`
|
||||
- `src/core/services/subtitle-cue-parser.ts`
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||
- `src/main.ts` (wire up prefetch service)
|
||||
|
||||
### Test Files
|
||||
|
||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||
- Updated tests for annotation stage (same behavior, new implementation)
|
||||
|
||||
@@ -27,6 +27,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional Silero detection groups short speech passages before transcription, decodes each independently, and restores original media timing without joining omitted gaps. `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/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-02
|
||||
Last verified: 2026-07-15
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -29,16 +29,6 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
|
||||
the loopback stream proxy separates request transport/retry from response transformation in
|
||||
`stream-strip-transport.ts` and `stream-strip-response.ts`;
|
||||
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
|
||||
`src/main/runtime/anime-browser-application-runtime.ts`, `src/main/runtime/anime-browser-runtime.ts`,
|
||||
`src/main/runtime/anime-browser-ipc-handlers.ts`, `src/main/runtime/anime-browser-sessions.ts`,
|
||||
`src/main/runtime/anime-bridge-installer.ts`, and `src/main/runtime/stream-playback-metadata.ts`.
|
||||
The play queue resolves episodes on click and appends them to mpv's real playlist
|
||||
(`src/main/runtime/anime-browser-queue.ts`), then observes media-path changes to
|
||||
attach prepared external tracks and update the browser queue state.
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
in `src/core/services/stats-server/`
|
||||
@@ -47,6 +37,14 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
## Shared Contract Entry Points
|
||||
|
||||
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
||||
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
||||
including changes whose cue text and timings are identical. Native selection and clean clipboard
|
||||
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
||||
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
||||
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
||||
on Chromium document focus or require activating the overlay window.
|
||||
|
||||
- Config + app-state contracts: `src/types/config.ts`
|
||||
- Subtitle/token/media annotation contracts: `src/types/subtitle.ts`
|
||||
- Runtime/window/controller/Electron bridge contracts: `src/types/runtime.ts`
|
||||
@@ -56,7 +54,6 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Settings UI contracts: `src/types/settings.ts`
|
||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
|
||||
- Anime browser contracts: `src/types/anime-browser.ts`, bridge wire types in `src/anime-bridge/types.ts`
|
||||
- Compatibility-only barrel: `src/types.ts`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
@@ -11,7 +11,7 @@ Read when: finding internal docs or checking verification status
|
||||
| --- | --- | --- | --- | --- |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-08-02 | runtime and feature ownership |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
|
||||
@@ -16,10 +16,6 @@ type AppCommandDeps = {
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
launchAnimeBrowserDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
@@ -27,8 +23,6 @@ const defaultAppCommandDeps: AppCommandDeps = {
|
||||
launchSyncUiDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
||||
launchAppBackgroundDetached,
|
||||
launchAnimeBrowserDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
|
||||
};
|
||||
|
||||
export function runAppPassthroughCommand(
|
||||
@@ -43,11 +37,6 @@ export function runAppPassthroughCommand(
|
||||
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (args.animeBrowser) {
|
||||
// Detached: the browser window is long-lived and owns the bridge process.
|
||||
deps.launchAnimeBrowserDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
|
||||
@@ -207,7 +207,6 @@ test('app command starts default macOS background app detached from launcher', (
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -228,7 +227,6 @@ test('app command starts default Linux background app detached from launcher', (
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -250,7 +248,6 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -269,7 +266,6 @@ test('sync UI command launches the app detached from the terminal', () => {
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
runAppCommandWithInherit: () => calls.push('piped'),
|
||||
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
|
||||
launchAnimeBrowserDetached: () => calls.push('anime'),
|
||||
launchAppBackgroundDetached: () => calls.push('detached'),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from '../config.js';
|
||||
import {
|
||||
createGenerationProgressReporter,
|
||||
runGenerateSubtitlesCommand,
|
||||
} from './generate-subtitles-command.js';
|
||||
|
||||
type Deps = NonNullable<Parameters<typeof runGenerateSubtitlesCommand>[1]>;
|
||||
|
||||
function fixture(argv: string[] = ['generate-subs', '/media/episode.mkv']) {
|
||||
const output: string[] = [];
|
||||
const commands: unknown[][] = [];
|
||||
const generations: Parameters<NonNullable<Deps['generate']>>[0][] = [];
|
||||
let exitCode: number | undefined;
|
||||
let interrupted: (() => void) | undefined;
|
||||
let detached = false;
|
||||
const context = {
|
||||
args: parseArgs(argv, 'subminer', {}),
|
||||
mpvSocketPath: '/tmp/test-subminer-socket',
|
||||
processAdapter: {
|
||||
writeStdout: (text: string) => {
|
||||
output.push(text);
|
||||
},
|
||||
setExitCode: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
},
|
||||
};
|
||||
const deps: Deps = {
|
||||
readConfig: () => ({ subtitleGeneration: { modelPath: '/models/external.bin' } }),
|
||||
configPath: () => '/settings/SubMiner/config.jsonc',
|
||||
resolveModel: async () => ({ kind: 'external', path: '/models/external.bin' }),
|
||||
downloadModel: async () => {
|
||||
throw new Error('Unexpected model download');
|
||||
},
|
||||
generate: async (input) => {
|
||||
generations.push(input);
|
||||
input.onProgress?.({
|
||||
stage: 'transcribe',
|
||||
percent: 50,
|
||||
message: 'Transcribing Japanese audio',
|
||||
});
|
||||
return '/media/episode.ja.srt';
|
||||
},
|
||||
mpvCommand: async (_socket, command) => {
|
||||
commands.push(command);
|
||||
if (command[1] === 'path') return '/media/episode.mkv';
|
||||
if (command[1] === 'track-list') return [{ type: 'audio', selected: true, 'ff-index': 2 }];
|
||||
return undefined;
|
||||
},
|
||||
onInterrupt: (handler) => {
|
||||
interrupted = handler;
|
||||
return () => {
|
||||
detached = true;
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
context,
|
||||
deps,
|
||||
output,
|
||||
commands,
|
||||
generations,
|
||||
exitCode: () => exitCode,
|
||||
interrupt: () => interrupted?.(),
|
||||
detached: () => detached,
|
||||
};
|
||||
}
|
||||
|
||||
test('launcher uses the shared core and selected mpv audio then loads the generated file', async () => {
|
||||
const f = fixture(['generate-subs']);
|
||||
assert.equal(await runGenerateSubtitlesCommand(f.context, f.deps), true);
|
||||
assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv');
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, 2);
|
||||
assert.equal(
|
||||
f.generations[0]?.modelDirectory,
|
||||
path.join('/settings/SubMiner', 'models', 'whisper'),
|
||||
);
|
||||
assert.equal(f.generations[0]?.config.modelPath, '/models/external.bin');
|
||||
assert.deepEqual(f.commands.at(-2), [
|
||||
'sub-add',
|
||||
'/media/episode.ja.srt',
|
||||
'select',
|
||||
'Japanese (generated)',
|
||||
'ja',
|
||||
]);
|
||||
assert.deepEqual(f.commands.at(-1), ['set_property', 'sub-delay', 0]);
|
||||
assert.match(f.output.join(''), /50%/);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles/);
|
||||
assert.equal(f.detached(), true);
|
||||
});
|
||||
|
||||
test('launcher never downloads a model without the explicit option', async () => {
|
||||
const f = fixture();
|
||||
f.deps.resolveModel = async () => ({ kind: 'missing', path: '/models/missing.bin' });
|
||||
await assert.rejects(runGenerateSubtitlesCommand(f.context, f.deps), /--download-model/);
|
||||
assert.equal(f.generations.length, 0);
|
||||
assert.equal(f.detached(), true);
|
||||
});
|
||||
|
||||
test('current mpv generation requires an identifiable selected audio track', async () => {
|
||||
for (const tracks of [
|
||||
[],
|
||||
[{ type: 'audio', selected: true }],
|
||||
[{ type: 'audio', selected: true, external: true, 'ff-index': 0 }],
|
||||
]) {
|
||||
const f = fixture(['generate-subs']);
|
||||
f.deps.mpvCommand = async (_socket, command) =>
|
||||
command[1] === 'path' ? '/media/episode.mkv' : tracks;
|
||||
await assert.rejects(runGenerateSubtitlesCommand(f.context, f.deps), /audio track/);
|
||||
assert.equal(f.generations.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit local file leaves Japanese track selection to the shared generator', async () => {
|
||||
const f = fixture();
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, undefined);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[1] === 'track-list'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('launcher does not load generated subtitles after mpv switches files', async () => {
|
||||
const f = fixture(['generate-subs']);
|
||||
let pathRequests = 0;
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
f.commands.push(command);
|
||||
if (command[1] === 'path')
|
||||
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
|
||||
return [{ type: 'audio', selected: true, 'ff-index': 2 }];
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles/);
|
||||
});
|
||||
|
||||
test('explicit managed model overrides external config and downloads before generation', async () => {
|
||||
const f = fixture([
|
||||
'generate-subs',
|
||||
'/media/episode.mkv',
|
||||
'--model',
|
||||
'medium',
|
||||
'--download-model',
|
||||
]);
|
||||
f.deps.resolveModel = async (config) => {
|
||||
assert.equal(config.modelPath, '');
|
||||
assert.equal(config.managedModel, 'medium');
|
||||
return { kind: 'missing', path: '/models/medium.bin' };
|
||||
};
|
||||
let downloaded = false;
|
||||
f.deps.downloadModel = async () => {
|
||||
downloaded = true;
|
||||
return '/models/medium.bin';
|
||||
};
|
||||
const generate = f.deps.generate;
|
||||
f.deps.generate = async (input) => {
|
||||
assert.equal(downloaded, true);
|
||||
if (!generate) throw new Error('Missing fixture generator');
|
||||
return generate(input);
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations.length, 1);
|
||||
});
|
||||
|
||||
test('generation can run standalone and never loads subtitles into another video', async () => {
|
||||
for (const playing of [null, '/media/different.mkv']) {
|
||||
const f = fixture();
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
f.commands.push(command);
|
||||
if (playing === null) throw new Error('mpv is not running');
|
||||
return playing;
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations.length, 1);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher preserves the saved path when loading into mpv fails', async () => {
|
||||
const f = fixture();
|
||||
const mpv = f.deps.mpvCommand;
|
||||
f.deps.mpvCommand = async (socket, command, timeout) => {
|
||||
if (command[0] === 'sub-add') throw new Error('load failed');
|
||||
return mpv?.(socket, command, timeout);
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles: \/media\/episode.ja.srt/);
|
||||
assert.match(f.output.join(''), /mpv could not load them: load failed/);
|
||||
assert.equal(f.exitCode(), 1);
|
||||
});
|
||||
|
||||
test('SIGINT cancels shared generation and unregisters its handler', async () => {
|
||||
const f = fixture();
|
||||
f.deps.generate = async (input) => {
|
||||
f.interrupt();
|
||||
assert.equal(input.signal?.aborted, true);
|
||||
throw new Error('Aborted');
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.exitCode(), 130);
|
||||
assert.equal(f.detached(), true);
|
||||
assert.match(f.output.join(''), /cancelled/);
|
||||
});
|
||||
|
||||
test('cancellation after generation preserves the saved path and skips mpv loading', async () => {
|
||||
const f = fixture();
|
||||
f.deps.generate = async () => {
|
||||
f.interrupt();
|
||||
return '/media/episode.ja.srt';
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles: \/media\/episode.ja.srt/);
|
||||
assert.equal(f.exitCode(), 130);
|
||||
assert.equal(f.detached(), true);
|
||||
});
|
||||
|
||||
test('progress throttles repeated updates but always reports stage changes and completion', () => {
|
||||
const output: string[] = [];
|
||||
let time = 0;
|
||||
const progress = createGenerationProgressReporter(
|
||||
(text) => output.push(text),
|
||||
() => time,
|
||||
);
|
||||
progress({ stage: 'download', percent: 0, message: 'Downloading' });
|
||||
progress({ stage: 'download', percent: 1, message: 'Downloading' });
|
||||
time = 1000;
|
||||
progress({ stage: 'download', percent: 50, message: 'Downloading' });
|
||||
progress({ stage: 'download', percent: 100, message: 'Downloading' });
|
||||
progress({ stage: 'extract', message: 'Extracting audio' });
|
||||
assert.equal(output.length, 4);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
downloadSubtitleGenerationModel,
|
||||
generateJapaneseSubtitles,
|
||||
resolveSubtitleGenerationModel,
|
||||
} from '../../src/core/services/subtitle-generation.js';
|
||||
import {
|
||||
resolveSubtitleGenerationConfig,
|
||||
type SubtitleGenerationProgress,
|
||||
} from '../../src/shared/subtitle-generation.js';
|
||||
import {
|
||||
readLauncherMainConfigObject,
|
||||
resolveLauncherMainConfigPath,
|
||||
} from '../config/shared-config-reader.js';
|
||||
import { sendMpvCommandWithResponse } from '../mpv.js';
|
||||
import { resolvePathMaybe } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type GenerationCommandContext = Pick<LauncherCommandContext, 'args' | 'mpvSocketPath'> & {
|
||||
processAdapter: Pick<LauncherCommandContext['processAdapter'], 'writeStdout' | 'setExitCode'>;
|
||||
};
|
||||
|
||||
interface GenerationCommandDeps {
|
||||
readConfig: typeof readLauncherMainConfigObject;
|
||||
configPath: typeof resolveLauncherMainConfigPath;
|
||||
resolveModel: typeof resolveSubtitleGenerationModel;
|
||||
downloadModel: typeof downloadSubtitleGenerationModel;
|
||||
generate: typeof generateJapaneseSubtitles;
|
||||
mpvCommand: typeof sendMpvCommandWithResponse;
|
||||
onInterrupt: (handler: () => void) => () => void;
|
||||
}
|
||||
|
||||
const defaultDeps: GenerationCommandDeps = {
|
||||
readConfig: readLauncherMainConfigObject,
|
||||
configPath: resolveLauncherMainConfigPath,
|
||||
resolveModel: resolveSubtitleGenerationModel,
|
||||
downloadModel: downloadSubtitleGenerationModel,
|
||||
generate: generateJapaneseSubtitles,
|
||||
mpvCommand: sendMpvCommandWithResponse,
|
||||
onInterrupt: (handler) => {
|
||||
process.on('SIGINT', handler);
|
||||
return () => process.off('SIGINT', handler);
|
||||
},
|
||||
};
|
||||
|
||||
function localMediaPath(value: string, workingDirectory = process.cwd()): string {
|
||||
if (value.startsWith('file://')) return fileURLToPath(value);
|
||||
if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) {
|
||||
throw new Error('Japanese subtitle generation requires a local media file.');
|
||||
}
|
||||
return path.resolve(workingDirectory, resolvePathMaybe(value));
|
||||
}
|
||||
|
||||
async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['mpvCommand']) {
|
||||
const media = await command(socketPath, ['get_property', 'path'], 1000);
|
||||
if (typeof media !== 'string' || !media.trim()) return null;
|
||||
let workingDirectory: string | undefined;
|
||||
if (!path.isAbsolute(media) && !media.startsWith('file://')) {
|
||||
const directory = await command(socketPath, ['get_property', 'working-directory'], 1000);
|
||||
if (typeof directory !== 'string') return null;
|
||||
workingDirectory = directory;
|
||||
}
|
||||
return localMediaPath(media, workingDirectory);
|
||||
}
|
||||
|
||||
async function readMpvAudioStream(
|
||||
socketPath: string,
|
||||
command: GenerationCommandDeps['mpvCommand'],
|
||||
) {
|
||||
const tracks = await command(socketPath, ['get_property', 'track-list'], 1000);
|
||||
for (const track of Array.isArray(tracks) ? tracks : []) {
|
||||
if (
|
||||
typeof track === 'object' &&
|
||||
track !== null &&
|
||||
'type' in track &&
|
||||
track.type === 'audio' &&
|
||||
'selected' in track &&
|
||||
track.selected === true
|
||||
) {
|
||||
if ('external' in track && track.external === true) {
|
||||
throw new Error(
|
||||
'The selected mpv audio track is external. Pass its local file to generate-subs.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
'ff-index' in track &&
|
||||
typeof track['ff-index'] === 'number' &&
|
||||
Number.isSafeInteger(track['ff-index']) &&
|
||||
track['ff-index'] >= 0
|
||||
) {
|
||||
return track['ff-index'];
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Select an audio track in mpv, or pass --audio-stream with its absolute stream index.',
|
||||
);
|
||||
}
|
||||
|
||||
function sameFile(left: string, right: string): boolean {
|
||||
try {
|
||||
return fs.realpathSync(left) === fs.realpathSync(right);
|
||||
} catch {
|
||||
return path.resolve(left) === path.resolve(right);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep progress readable in terminals and redirected logs, even for large model downloads. */
|
||||
export function createGenerationProgressReporter(write: (text: string) => void, now = Date.now) {
|
||||
let previousStage: SubtitleGenerationProgress['stage'] | undefined;
|
||||
let previousTime = -Infinity;
|
||||
let previousLine = '';
|
||||
return (progress: SubtitleGenerationProgress): void => {
|
||||
const percent =
|
||||
typeof progress.percent === 'number' && Number.isFinite(progress.percent)
|
||||
? Math.floor(Math.max(0, Math.min(100, progress.percent)))
|
||||
: undefined;
|
||||
const line = `[${progress.stage}] ${percent === undefined ? '' : `${percent}% `}${progress.message}\n`;
|
||||
const timestamp = now();
|
||||
if (
|
||||
line === previousLine ||
|
||||
(progress.stage === previousStage && timestamp - previousTime < 1000 && percent !== 100)
|
||||
)
|
||||
return;
|
||||
write(line);
|
||||
previousStage = progress.stage;
|
||||
previousTime = timestamp;
|
||||
previousLine = line;
|
||||
};
|
||||
}
|
||||
|
||||
export async function runGenerateSubtitlesCommand(
|
||||
context: GenerationCommandContext,
|
||||
overrides: Partial<GenerationCommandDeps> = {},
|
||||
): Promise<boolean> {
|
||||
const options = context.args.generateSubtitles;
|
||||
if (!options) return false;
|
||||
const deps = { ...defaultDeps, ...overrides };
|
||||
const write = (text: string) => context.processAdapter.writeStdout(text);
|
||||
const controller = new AbortController();
|
||||
const removeInterrupt = deps.onInterrupt(() => controller.abort());
|
||||
try {
|
||||
const config = resolveSubtitleGenerationConfig(deps.readConfig()?.subtitleGeneration);
|
||||
if (options.managedModel) {
|
||||
config.managedModel = options.managedModel;
|
||||
config.modelPath = '';
|
||||
}
|
||||
if (options.modelPath !== undefined)
|
||||
config.modelPath = path.resolve(resolvePathMaybe(options.modelPath));
|
||||
const modelDirectory = path.join(path.dirname(deps.configPath()), 'models', 'whisper');
|
||||
const currentMedia = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
|
||||
() => null,
|
||||
);
|
||||
const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia;
|
||||
if (!mediaPath)
|
||||
throw new Error('Pass a local video file or open one in mpv before running generate-subs.');
|
||||
const audioStreamIndex =
|
||||
options.audioStreamIndex ??
|
||||
(!options.mediaPath
|
||||
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
|
||||
: undefined);
|
||||
const onProgress = createGenerationProgressReporter(write);
|
||||
const model = await deps.resolveModel(config, modelDirectory);
|
||||
if (model.kind === 'invalid') throw new Error(model.message);
|
||||
if (model.kind === 'missing') {
|
||||
if (!options.downloadModel) {
|
||||
throw new Error(
|
||||
'No Whisper model found. Run again with --download-model, or set subtitleGeneration.modelPath / --model-path.',
|
||||
);
|
||||
}
|
||||
await deps.downloadModel({ config, modelDirectory, onProgress, signal: controller.signal });
|
||||
}
|
||||
const outputPath = await deps.generate({
|
||||
config,
|
||||
modelDirectory,
|
||||
mediaPath,
|
||||
audioStreamIndex,
|
||||
outputPath: options.outputPath
|
||||
? path.resolve(resolvePathMaybe(options.outputPath))
|
||||
: undefined,
|
||||
onProgress,
|
||||
signal: controller.signal,
|
||||
});
|
||||
write(`Saved Japanese subtitles: ${outputPath}\n`);
|
||||
controller.signal.throwIfAborted();
|
||||
const playingMedia = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
|
||||
() => null,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
if (playingMedia && sameFile(playingMedia, mediaPath)) {
|
||||
try {
|
||||
await deps.mpvCommand(context.mpvSocketPath, [
|
||||
'sub-add',
|
||||
outputPath,
|
||||
'select',
|
||||
'Japanese (generated)',
|
||||
'ja',
|
||||
]);
|
||||
await deps.mpvCommand(context.mpvSocketPath, ['set_property', 'sub-delay', 0]);
|
||||
write('Loaded Japanese subtitles into mpv.\n');
|
||||
} catch (error) {
|
||||
write(
|
||||
`Subtitles are saved, but mpv could not load them: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
context.processAdapter.setExitCode(1);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) throw error;
|
||||
write('Subtitle generation cancelled.\n');
|
||||
context.processAdapter.setExitCode(130);
|
||||
return true;
|
||||
} finally {
|
||||
removeInterrupt();
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@ function createContext(): LauncherCommandContext {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -120,7 +120,6 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
logLevel: 'warn',
|
||||
},
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -175,7 +174,6 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -223,7 +221,6 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
action: undefined,
|
||||
},
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -269,7 +266,6 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
|
||||
@@ -170,7 +170,6 @@ export function createDefaultArgs(
|
||||
version: false,
|
||||
update: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -249,6 +248,7 @@ export function applyRootOptionsToArgs(
|
||||
}
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.generateSubtitles) parsed.generateSubtitles = invocations.generateSubtitles;
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true;
|
||||
if (invocations.dictionarySelect) parsed.dictionarySelect = true;
|
||||
@@ -356,12 +356,6 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
);
|
||||
}
|
||||
|
||||
if (invocations.animeInvocation) {
|
||||
if (invocations.animeInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.animeInvocation.logLevel);
|
||||
}
|
||||
parsed.animeBrowser = true;
|
||||
}
|
||||
if (invocations.settingsInvocation) {
|
||||
if (invocations.settingsInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseCliPrograms, resolveTopLevelCommand } from './cli-parser-builder.js';
|
||||
import { SUBTITLE_GENERATION_MODELS } from '../../src/shared/subtitle-generation-model-catalog.js';
|
||||
|
||||
test('generate-subs accepts all downloadable multilingual model variants', () => {
|
||||
for (const { id } of SUBTITLE_GENERATION_MODELS) {
|
||||
const { invocations } = parseCliPrograms(['generate-subs', '--model', id], 'subminer');
|
||||
assert.equal(invocations.generateSubtitles?.managedModel, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('generate-subs parses local generation options separately from YouTube options', () => {
|
||||
const result = parseCliPrograms(
|
||||
[
|
||||
'generate-subs',
|
||||
'episode.mkv',
|
||||
'--download-model',
|
||||
'--model',
|
||||
'medium',
|
||||
'--output',
|
||||
'episode.ja.srt',
|
||||
'--audio-stream',
|
||||
'2',
|
||||
],
|
||||
'subminer',
|
||||
);
|
||||
assert.deepEqual(result.invocations.generateSubtitles, {
|
||||
mediaPath: 'episode.mkv',
|
||||
downloadModel: true,
|
||||
managedModel: 'medium',
|
||||
modelPath: undefined,
|
||||
outputPath: 'episode.ja.srt',
|
||||
audioStreamIndex: 2,
|
||||
});
|
||||
assert.equal(
|
||||
parseCliPrograms(['generate-subs'], 'subminer').invocations.generateSubtitles?.mediaPath,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
parseCliPrograms(['generate-subs', '--model-path', '/models/ggml.bin'], 'subminer').invocations
|
||||
.generateSubtitles?.modelPath,
|
||||
'/models/ggml.bin',
|
||||
);
|
||||
});
|
||||
|
||||
test('generate-subs rejects conflicting models and malformed audio stream indices', () => {
|
||||
for (const flags of [
|
||||
['--model', 'tiny.en'],
|
||||
['--model', 'small.en-q5_1'],
|
||||
['--model', 'toString'],
|
||||
['--audio-stream', '-1'],
|
||||
['--audio-stream', '1.5'],
|
||||
['--model-path', '/model.bin', '--download-model'],
|
||||
['--model-path', '/model.bin', '--model', 'small'],
|
||||
])
|
||||
assert.throws(() => parseCliPrograms(['generate-subs', ...flags], 'subminer'), /Generation/);
|
||||
});
|
||||
|
||||
test('resolveTopLevelCommand skips root options and finds the first command', () => {
|
||||
assert.deepEqual(resolveTopLevelCommand(['--backend', 'macos', 'config', 'show']), {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Command } from 'commander';
|
||||
import type { Args } from '../types.js';
|
||||
import {
|
||||
isSubtitleGenerationModelId,
|
||||
SUBTITLE_GENERATION_MODELS,
|
||||
} from '../../src/shared/subtitle-generation-model-catalog.js';
|
||||
|
||||
export interface JellyfinInvocation {
|
||||
action?: string;
|
||||
@@ -20,10 +25,10 @@ export interface CommandActionInvocation {
|
||||
}
|
||||
|
||||
export interface CliInvocations {
|
||||
generateSubtitles?: Args['generateSubtitles'];
|
||||
jellyfinInvocation: JellyfinInvocation | null;
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
settingsInvocation: CommandActionInvocation | null;
|
||||
animeInvocation: CommandActionInvocation | null;
|
||||
mpvInvocation: CommandActionInvocation | null;
|
||||
appInvocation: { appArgs: string[] } | null;
|
||||
dictionaryTriggered: boolean;
|
||||
@@ -116,10 +121,10 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'doctor',
|
||||
'config',
|
||||
'settings',
|
||||
'anime',
|
||||
'mpv',
|
||||
'logs',
|
||||
'dictionary',
|
||||
'generate-subs',
|
||||
'dict',
|
||||
'stats',
|
||||
'sync',
|
||||
@@ -170,7 +175,6 @@ export function parseCliPrograms(
|
||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||
let configInvocation: CommandActionInvocation | null = null;
|
||||
let settingsInvocation: CommandActionInvocation | null = null;
|
||||
let animeInvocation: CommandActionInvocation | null = null;
|
||||
let mpvInvocation: CommandActionInvocation | null = null;
|
||||
let appInvocation: { appArgs: string[] } | null = null;
|
||||
let dictionaryTriggered = false;
|
||||
@@ -202,6 +206,7 @@ export function parseCliPrograms(
|
||||
let texthookerOpenBrowser = false;
|
||||
let doctorTriggered = false;
|
||||
let texthookerTriggered = false;
|
||||
let generateSubtitles: Args['generateSubtitles'];
|
||||
|
||||
const commandProgram = new Command();
|
||||
commandProgram
|
||||
@@ -226,6 +231,50 @@ export function parseCliPrograms(
|
||||
.argument('[target]', 'file, directory, or URL');
|
||||
applyRootOptions(rootProgram);
|
||||
|
||||
commandProgram
|
||||
.command('generate-subs')
|
||||
.description('Generate Japanese subtitles locally with whisper.cpp')
|
||||
.argument('[video]', 'Local media file, or the current mpv file if omitted')
|
||||
.option('--download-model', 'Download the selected managed model if missing')
|
||||
.option('--model-path <path>', 'Use an existing whisper.cpp model file')
|
||||
.option(
|
||||
'--model <name>',
|
||||
`Managed model: ${SUBTITLE_GENERATION_MODELS.map((model) => model.id).join(', ')}`,
|
||||
)
|
||||
.option('--output <path>', 'Save subtitles to this SRT path')
|
||||
.option('--audio-stream <index>', 'Absolute audio stream index from ffprobe')
|
||||
.action((mediaPath: string | undefined, options: Record<string, unknown>) => {
|
||||
const model = options.model;
|
||||
if (model !== undefined && !isSubtitleGenerationModelId(model)) {
|
||||
throw new Error(
|
||||
`Generation --model must be one of: ${SUBTITLE_GENERATION_MODELS.map((entry) => entry.id).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.modelPath !== undefined &&
|
||||
(model !== undefined || options.downloadModel === true)
|
||||
) {
|
||||
throw new Error(
|
||||
'Generation --model-path cannot be combined with --model or --download-model.',
|
||||
);
|
||||
}
|
||||
let audioStreamIndex: number | undefined;
|
||||
if (typeof options.audioStream === 'string') {
|
||||
audioStreamIndex = Number(options.audioStream);
|
||||
if (!/^\d+$/.test(options.audioStream) || !Number.isSafeInteger(audioStreamIndex)) {
|
||||
throw new Error('Generation --audio-stream must be a non-negative integer stream index.');
|
||||
}
|
||||
}
|
||||
generateSubtitles = {
|
||||
mediaPath,
|
||||
downloadModel: options.downloadModel === true,
|
||||
modelPath: typeof options.modelPath === 'string' ? options.modelPath : undefined,
|
||||
managedModel: model,
|
||||
outputPath: typeof options.output === 'string' ? options.output : undefined,
|
||||
audioStreamIndex,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('jellyfin')
|
||||
.alias('jf')
|
||||
@@ -459,16 +508,6 @@ export function parseCliPrograms(
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('anime')
|
||||
.description('Open the anime browser window')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
animeInvocation = {
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('mpv')
|
||||
.description('MPV helpers')
|
||||
@@ -520,10 +559,10 @@ export function parseCliPrograms(
|
||||
options: selectedProgram.opts<Record<string, unknown>>(),
|
||||
rootTarget: rootProgram.processedArgs[0],
|
||||
invocations: {
|
||||
generateSubtitles,
|
||||
jellyfinInvocation,
|
||||
configInvocation,
|
||||
settingsInvocation,
|
||||
animeInvocation,
|
||||
mpvInvocation,
|
||||
appInvocation,
|
||||
dictionaryTriggered,
|
||||
|
||||
@@ -57,7 +57,6 @@ function createArgs(): Args {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { runHistorySession } from './commands/history-command.js';
|
||||
import { runSyncCommand } from './commands/sync-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
import { runGenerateSubtitlesCommand } from './commands/generate-subtitles-command.js';
|
||||
|
||||
const APP_VERSION =
|
||||
typeof packageJson.version === 'string' && packageJson.version.trim()
|
||||
@@ -112,6 +113,10 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runGenerateSubtitlesCommand(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAppPath = ensureAppPath(context);
|
||||
state.appPath = resolvedAppPath;
|
||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||
|
||||
@@ -627,7 +627,6 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
+9
-1
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
||||
import type { SubtitleGenerationConfig } from '../src/shared/subtitle-generation.js';
|
||||
import {
|
||||
resolveDefaultLogFilePath,
|
||||
type LogFileToggles,
|
||||
@@ -88,6 +89,14 @@ export interface LauncherAiConfig {
|
||||
}
|
||||
|
||||
export interface Args {
|
||||
generateSubtitles?: {
|
||||
mediaPath?: string;
|
||||
downloadModel: boolean;
|
||||
modelPath?: string;
|
||||
managedModel?: SubtitleGenerationConfig['managedModel'];
|
||||
outputPath?: string;
|
||||
audioStreamIndex?: number;
|
||||
};
|
||||
backend: Backend;
|
||||
directory: string;
|
||||
recursive: boolean;
|
||||
@@ -152,7 +161,6 @@ export interface Args {
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
settings: boolean;
|
||||
animeBrowser: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
+2
-3
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.5",
|
||||
"version": "0.19.6",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -21,11 +21,10 @@
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:animeui && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
"build:animeui": "esbuild src/animeui/animeui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/animeui/animeui.js --sourcemap && esbuild src/preload-animeui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-animeui.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build-release",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pkgbase = subminer-bin
|
||||
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
|
||||
pkgver = 0.19.5
|
||||
pkgrel = 2
|
||||
pkgver = 0.6.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/ksyasuda/SubMiner
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
@@ -21,17 +21,16 @@ pkgbase = subminer-bin
|
||||
optdepends = python-guessit: improved AniSkip title and episode inference
|
||||
optdepends = alass-git: preferred subtitle synchronization engine
|
||||
optdepends = python-ffsubsync: fallback subtitle synchronization engine
|
||||
optdepends = mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman
|
||||
provides = subminer=0.19.5
|
||||
provides = subminer=0.6.2
|
||||
conflicts = subminer
|
||||
noextract = SubMiner-0.19.5.AppImage
|
||||
noextract = SubMiner-0.6.2.AppImage
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = SubMiner-0.19.5.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/SubMiner-0.19.5.AppImage
|
||||
source = subminer-0.19.5::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/subminer
|
||||
source = subminer-assets-0.19.5.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/subminer-assets.tar.gz
|
||||
sha256sums = bd683d949956ff847927f23acfede49efad6320cf7ec4cdc3cbd586a5287884e
|
||||
sha256sums = 2fa292f672df715461765aef8d5c6bf3612004c8b8070b1b1f0afa10d4b74c1e
|
||||
sha256sums = 591468a1fc68a13de315acaf1154df9da41fe3bcbc0e5522a04ba21e939ed1ae
|
||||
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
|
||||
source = subminer-0.6.2::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
|
||||
source = subminer-assets-0.6.2.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
|
||||
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
|
||||
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
|
||||
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
|
||||
|
||||
pkgname = subminer-bin
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Maintainer: Kyle Yasuda <suda@sudacode.com>
|
||||
|
||||
pkgname=subminer-bin
|
||||
pkgver=0.19.5
|
||||
pkgrel=2
|
||||
pkgver=0.6.2
|
||||
pkgrel=1
|
||||
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/ksyasuda/SubMiner'
|
||||
@@ -27,7 +27,6 @@ optdepends=(
|
||||
'python-guessit: improved AniSkip title and episode inference'
|
||||
'alass-git: preferred subtitle synchronization engine'
|
||||
'python-ffsubsync: fallback subtitle synchronization engine'
|
||||
'mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman'
|
||||
)
|
||||
provides=("subminer=${pkgver}")
|
||||
conflicts=('subminer')
|
||||
@@ -37,9 +36,9 @@ source=(
|
||||
"subminer-assets-${pkgver}.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
|
||||
)
|
||||
sha256sums=(
|
||||
'bd683d949956ff847927f23acfede49efad6320cf7ec4cdc3cbd586a5287884e'
|
||||
'2fa292f672df715461765aef8d5c6bf3612004c8b8070b1b1f0afa10d4b74c1e'
|
||||
'591468a1fc68a13de315acaf1154df9da41fe3bcbc0e5522a04ba21e939ed1ae'
|
||||
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
|
||||
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
|
||||
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
|
||||
)
|
||||
noextract=("SubMiner-${pkgver}.AppImage")
|
||||
|
||||
|
||||
@@ -268,8 +268,6 @@ function M.create(ctx)
|
||||
return { "--open-controller-debug" }
|
||||
elseif action_id == "openPlaylistBrowser" then
|
||||
return { "--open-playlist-browser" }
|
||||
elseif action_id == "openAnimeBrowser" then
|
||||
return { "--session-action", '{"actionId":"openAnimeBrowser"}' }
|
||||
elseif action_id == "replayCurrentSubtitle" then
|
||||
return { "--replay-current-subtitle" }
|
||||
elseif action_id == "playNextSubtitle" then
|
||||
|
||||
@@ -3,40 +3,14 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function buildScript(name: string): string {
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
|
||||
) as { scripts: Record<string, string> };
|
||||
return packageJson.scripts[name] ?? '';
|
||||
}
|
||||
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const command = buildScript('build:syncui');
|
||||
const command = packageJson.scripts['build:syncui'] ?? '';
|
||||
|
||||
assert.match(command, /src\/preload-syncui\.ts/);
|
||||
assert.match(command, /--bundle/);
|
||||
assert.match(command, /--external:electron/);
|
||||
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
|
||||
});
|
||||
|
||||
test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const command = buildScript('build:animeui');
|
||||
const sharedPreloadSource = fs.readFileSync(
|
||||
path.join(import.meta.dir, '..', 'src', 'preload-anime-browser-api.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The preload imports IPC_CHANNELS, so it must be bundled rather than
|
||||
// emitted by plain tsc with a relative runtime require.
|
||||
assert.match(command, /src\/preload-animeui\.ts/);
|
||||
assert.match(command, /--bundle/);
|
||||
assert.match(command, /--external:electron/);
|
||||
assert.match(command, /--outfile=dist\/preload-animeui\.js/);
|
||||
// The standalone Anime window uses Electron's sandboxed preload runtime,
|
||||
// which exposes `electron` but cannot require arbitrary Node built-ins.
|
||||
assert.doesNotMatch(sharedPreloadSource, /from ['"]node:/);
|
||||
});
|
||||
|
||||
test('build:animeui runs as part of the top-level build', () => {
|
||||
assert.match(buildScript('build'), /bun run build:animeui/);
|
||||
});
|
||||
|
||||
@@ -6,15 +6,12 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const assetsSourceDir = path.join(repoRoot, 'assets');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||
const animeUiSourceDir = path.join(repoRoot, 'src', 'animeui');
|
||||
const animeUiOutputDir = path.join(repoRoot, 'dist', 'animeui');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
@@ -29,11 +26,9 @@ function copyFile(sourcePath, outputPath) {
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyAssets(sourceDir, outputDir, label, stylesheets = ['style.css']) {
|
||||
function copyAssets(sourceDir, outputDir, label) {
|
||||
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
|
||||
for (const stylesheet of stylesheets) {
|
||||
copyFile(path.join(sourceDir, stylesheet), path.join(outputDir, stylesheet));
|
||||
}
|
||||
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
@@ -53,15 +48,6 @@ function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function copyAnimeUiAssets() {
|
||||
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui', [
|
||||
'style.css',
|
||||
'detail.css',
|
||||
'panels.css',
|
||||
]);
|
||||
copyFile(path.join(assetsSourceDir, 'SubMiner.png'), path.join(animeUiOutputDir, 'SubMiner.png'));
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
@@ -119,7 +105,6 @@ function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
copyAnimeUiAssets();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
|
||||
@@ -19,26 +19,6 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
||||
);
|
||||
});
|
||||
|
||||
test('anime UI stylesheet files exist and are all staged', () => {
|
||||
const html = readFileSync('src/animeui/index.html', 'utf8');
|
||||
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="\.\/(.+?\.css)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
assert.deepEqual(stylesheets, ['style.css', 'detail.css', 'panels.css']);
|
||||
for (const stylesheet of stylesheets) {
|
||||
assert.equal(existsSync(`src/animeui/${stylesheet}`), true, `${stylesheet} must exist`);
|
||||
}
|
||||
assert.match(
|
||||
source,
|
||||
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/copyFile\(\s*path\.join\(assetsSourceDir, 'SubMiner\.png'\),\s*path\.join\(animeUiOutputDir, 'SubMiner\.png'\),?\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
||||
// version as the helper's minimum, so released builds refuse to load on older macOS.
|
||||
test('macOS helper is compiled with an explicit deployment target', () => {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { parseAndroidManifestVersionCode, readApkVersionCode } from './apk-version';
|
||||
|
||||
const NO_STRING = 0xffffffff;
|
||||
|
||||
function writeChunkHeader(buffer: Buffer, type: number, headerSize: number, size: number): void {
|
||||
buffer.writeUInt16LE(type, 0);
|
||||
buffer.writeUInt16LE(headerSize, 2);
|
||||
buffer.writeUInt32LE(size, 4);
|
||||
}
|
||||
|
||||
function makeStringPool(strings: string[]): Buffer {
|
||||
const encoded = strings.map((value) => {
|
||||
const bytes = Buffer.from(value, 'utf8');
|
||||
return Buffer.concat([Buffer.from([value.length, bytes.length]), bytes, Buffer.from([0])]);
|
||||
});
|
||||
const offsets = encoded.map((_value, index) =>
|
||||
encoded.slice(0, index).reduce((total, value) => total + value.length, 0),
|
||||
);
|
||||
const dataLength = encoded.reduce((total, value) => total + value.length, 0);
|
||||
const paddedDataLength = Math.ceil(dataLength / 4) * 4;
|
||||
const headerSize = 28;
|
||||
const stringsStart = headerSize + strings.length * 4;
|
||||
const chunk = Buffer.alloc(stringsStart + paddedDataLength);
|
||||
writeChunkHeader(chunk, 0x0001, headerSize, chunk.length);
|
||||
chunk.writeUInt32LE(strings.length, 8);
|
||||
chunk.writeUInt32LE(0x00000100, 16);
|
||||
chunk.writeUInt32LE(stringsStart, 20);
|
||||
offsets.forEach((offset, index) => chunk.writeUInt32LE(offset, headerSize + index * 4));
|
||||
Buffer.concat(encoded).copy(chunk, stringsStart);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function makeBinaryManifest(versionCode: number): Buffer {
|
||||
const stringPool = makeStringPool(['manifest', 'versionCode']);
|
||||
const startElement = Buffer.alloc(56);
|
||||
writeChunkHeader(startElement, 0x0102, 16, startElement.length);
|
||||
startElement.writeUInt32LE(NO_STRING, 12);
|
||||
startElement.writeUInt32LE(NO_STRING, 16);
|
||||
startElement.writeUInt32LE(0, 20);
|
||||
startElement.writeUInt16LE(20, 24);
|
||||
startElement.writeUInt16LE(20, 26);
|
||||
startElement.writeUInt16LE(1, 28);
|
||||
const attributeOffset = 36;
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset);
|
||||
startElement.writeUInt32LE(1, attributeOffset + 4);
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset + 8);
|
||||
startElement.writeUInt16LE(8, attributeOffset + 12);
|
||||
startElement[attributeOffset + 15] = 0x10;
|
||||
startElement.writeUInt32LE(versionCode, attributeOffset + 16);
|
||||
|
||||
const document = Buffer.alloc(8);
|
||||
writeChunkHeader(document, 0x0003, 8, document.length + stringPool.length + startElement.length);
|
||||
return Buffer.concat([document, stringPool, startElement]);
|
||||
}
|
||||
|
||||
function makeDeflatedZip(name: string, data: Buffer): Buffer {
|
||||
const fileName = Buffer.from(name, 'utf8');
|
||||
const compressed = deflateRawSync(data);
|
||||
const local = Buffer.alloc(30 + fileName.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(fileName.length, 26);
|
||||
fileName.copy(local, 30);
|
||||
|
||||
const central = Buffer.alloc(46 + fileName.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(fileName.length, 28);
|
||||
fileName.copy(central, 46);
|
||||
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(central.length, 12);
|
||||
end.writeUInt32LE(local.length + compressed.length, 16);
|
||||
return Buffer.concat([local, compressed, central, end]);
|
||||
}
|
||||
|
||||
test('parseAndroidManifestVersionCode reads the typed manifest attribute', () => {
|
||||
assert.equal(parseAndroidManifestVersionCode(makeBinaryManifest(42)), 42);
|
||||
assert.equal(parseAndroidManifestVersionCode(Buffer.from('plain xml')), null);
|
||||
});
|
||||
|
||||
test('readApkVersionCode reads a deflated AndroidManifest.xml from an APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'extension.apk');
|
||||
await writeFile(apkPath, makeDeflatedZip('AndroidManifest.xml', makeBinaryManifest(730)));
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), 730);
|
||||
});
|
||||
|
||||
test('readApkVersionCode returns null for a malformed APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'broken.apk');
|
||||
await writeFile(apkPath, 'not a zip');
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), null);
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ANDROID_XML_TYPE = 0x0003;
|
||||
const STRING_POOL_TYPE = 0x0001;
|
||||
const START_ELEMENT_TYPE = 0x0102;
|
||||
const UTF8_STRING_POOL_FLAG = 0x00000100;
|
||||
const NO_STRING = 0xffffffff;
|
||||
const TYPE_INT_DEC = 0x10;
|
||||
const TYPE_INT_HEX = 0x11;
|
||||
const MANIFEST_ENTRY = 'AndroidManifest.xml';
|
||||
const MAX_ZIP_TAIL_BYTES = 65_535 + 22;
|
||||
const MAX_CENTRAL_DIRECTORY_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
interface ZipEntryLocation {
|
||||
compressionMethod: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
async function readExactly(handle: FileHandle, length: number, position: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
||||
if (bytesRead !== length) throw new Error('Unexpected end of APK.');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(tail: Buffer): number | null {
|
||||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||||
if (tail.readUInt32LE(offset) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
|
||||
const commentLength = tail.readUInt16LE(offset + 20);
|
||||
if (offset + 22 + commentLength === tail.length) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findZipEntry(
|
||||
central: Buffer,
|
||||
entryCount: number,
|
||||
wantedName: string,
|
||||
): ZipEntryLocation | null {
|
||||
let offset = 0;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > central.length) return null;
|
||||
if (central.readUInt32LE(offset) !== CENTRAL_FILE_HEADER_SIGNATURE) return null;
|
||||
|
||||
const flags = central.readUInt16LE(offset + 8);
|
||||
const compressionMethod = central.readUInt16LE(offset + 10);
|
||||
const compressedSize = central.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = central.readUInt32LE(offset + 24);
|
||||
const nameLength = central.readUInt16LE(offset + 28);
|
||||
const extraLength = central.readUInt16LE(offset + 30);
|
||||
const commentLength = central.readUInt16LE(offset + 32);
|
||||
const recordLength = 46 + nameLength + extraLength + commentLength;
|
||||
if (offset + recordLength > central.length) return null;
|
||||
|
||||
const name = central.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
||||
if (name === wantedName) {
|
||||
if ((flags & 0x0001) !== 0) return null;
|
||||
if (compressedSize > MAX_MANIFEST_BYTES || uncompressedSize > MAX_MANIFEST_BYTES) return null;
|
||||
return {
|
||||
compressionMethod,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset: central.readUInt32LE(offset + 42),
|
||||
};
|
||||
}
|
||||
offset += recordLength;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readZipEntry(apkPath: string, wantedName: string): Promise<Buffer | null> {
|
||||
let handle: FileHandle | null = null;
|
||||
try {
|
||||
handle = await open(apkPath, 'r');
|
||||
const fileSize = (await handle.stat()).size;
|
||||
const tailSize = Math.min(fileSize, MAX_ZIP_TAIL_BYTES);
|
||||
if (tailSize < 22) return null;
|
||||
const tail = await readExactly(handle, tailSize, fileSize - tailSize);
|
||||
const endOffset = findEndOfCentralDirectory(tail);
|
||||
if (endOffset === null) return null;
|
||||
|
||||
const diskNumber = tail.readUInt16LE(endOffset + 4);
|
||||
const centralDisk = tail.readUInt16LE(endOffset + 6);
|
||||
const diskEntryCount = tail.readUInt16LE(endOffset + 8);
|
||||
const entryCount = tail.readUInt16LE(endOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(endOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(endOffset + 16);
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0 ||
|
||||
centralSize === 0 ||
|
||||
centralSize > MAX_CENTRAL_DIRECTORY_BYTES ||
|
||||
centralOffset + centralSize > fileSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const central = await readExactly(handle, centralSize, centralOffset);
|
||||
const entry = findZipEntry(central, entryCount, wantedName);
|
||||
if (!entry || entry.localHeaderOffset + 30 > centralOffset) return null;
|
||||
|
||||
const localHeader = await readExactly(handle, 30, entry.localHeaderOffset);
|
||||
if (localHeader.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) return null;
|
||||
const nameLength = localHeader.readUInt16LE(26);
|
||||
const extraLength = localHeader.readUInt16LE(28);
|
||||
const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
if (dataOffset + entry.compressedSize > centralOffset) return null;
|
||||
const compressed = await readExactly(handle, entry.compressedSize, dataOffset);
|
||||
|
||||
let data: Buffer;
|
||||
if (entry.compressionMethod === 0) {
|
||||
data = compressed;
|
||||
} else if (entry.compressionMethod === 8) {
|
||||
data = inflateRawSync(compressed, { maxOutputLength: MAX_MANIFEST_BYTES });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return data.length === entry.uncompressedSize ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function readUtf8Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset >= buffer.length) return null;
|
||||
const first = buffer[offset]!;
|
||||
if ((first & 0x80) === 0) return { length: first, next: offset + 1 };
|
||||
if (offset + 1 >= buffer.length) return null;
|
||||
return { length: ((first & 0x7f) << 8) | buffer[offset + 1]!, next: offset + 2 };
|
||||
}
|
||||
|
||||
function readUtf16Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset + 2 > buffer.length) return null;
|
||||
const first = buffer.readUInt16LE(offset);
|
||||
if ((first & 0x8000) === 0) return { length: first, next: offset + 2 };
|
||||
if (offset + 4 > buffer.length) return null;
|
||||
return {
|
||||
length: ((first & 0x7fff) << 16) | buffer.readUInt16LE(offset + 2),
|
||||
next: offset + 4,
|
||||
};
|
||||
}
|
||||
|
||||
interface AndroidStringPool {
|
||||
stringAt: (index: number) => string | null;
|
||||
}
|
||||
|
||||
function parseStringPool(
|
||||
buffer: Buffer,
|
||||
chunkOffset: number,
|
||||
chunkSize: number,
|
||||
): AndroidStringPool | null {
|
||||
const headerSize = buffer.readUInt16LE(chunkOffset + 2);
|
||||
if (headerSize < 28 || chunkOffset + chunkSize > buffer.length) return null;
|
||||
const stringCount = buffer.readUInt32LE(chunkOffset + 8);
|
||||
const flags = buffer.readUInt32LE(chunkOffset + 16);
|
||||
const stringsStart = buffer.readUInt32LE(chunkOffset + 20);
|
||||
if (headerSize + stringCount * 4 > chunkSize || stringsStart >= chunkSize) return null;
|
||||
|
||||
return {
|
||||
stringAt(index) {
|
||||
if (index === NO_STRING || index >= stringCount) return null;
|
||||
const relativeOffset = buffer.readUInt32LE(chunkOffset + headerSize + index * 4);
|
||||
let stringOffset = chunkOffset + stringsStart + relativeOffset;
|
||||
const chunkEnd = chunkOffset + chunkSize;
|
||||
if (stringOffset >= chunkEnd) return null;
|
||||
|
||||
if ((flags & UTF8_STRING_POOL_FLAG) !== 0) {
|
||||
const utf16Length = readUtf8Length(buffer, stringOffset);
|
||||
if (!utf16Length) return null;
|
||||
const byteLength = readUtf8Length(buffer, utf16Length.next);
|
||||
if (!byteLength || byteLength.next + byteLength.length > chunkEnd) return null;
|
||||
return buffer.toString('utf8', byteLength.next, byteLength.next + byteLength.length);
|
||||
}
|
||||
|
||||
const length = readUtf16Length(buffer, stringOffset);
|
||||
if (!length) return null;
|
||||
stringOffset = length.next;
|
||||
const byteLength = length.length * 2;
|
||||
if (stringOffset + byteLength > chunkEnd) return null;
|
||||
return buffer.toString('utf16le', stringOffset, stringOffset + byteLength);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Read Android's numeric version code from a binary AndroidManifest.xml. */
|
||||
export function parseAndroidManifestVersionCode(buffer: Buffer): number | null {
|
||||
try {
|
||||
if (buffer.length < 8 || buffer.readUInt16LE(0) !== ANDROID_XML_TYPE) return null;
|
||||
const documentSize = buffer.readUInt32LE(4);
|
||||
if (documentSize > buffer.length) return null;
|
||||
|
||||
let strings: AndroidStringPool | null = null;
|
||||
let offset = buffer.readUInt16LE(2);
|
||||
while (offset + 8 <= documentSize) {
|
||||
const chunkType = buffer.readUInt16LE(offset);
|
||||
const headerSize = buffer.readUInt16LE(offset + 2);
|
||||
const chunkSize = buffer.readUInt32LE(offset + 4);
|
||||
if (headerSize < 8 || chunkSize < headerSize || offset + chunkSize > documentSize)
|
||||
return null;
|
||||
|
||||
if (chunkType === STRING_POOL_TYPE) {
|
||||
strings = parseStringPool(buffer, offset, chunkSize);
|
||||
} else if (chunkType === START_ELEMENT_TYPE && strings && headerSize >= 16) {
|
||||
const elementName = strings.stringAt(buffer.readUInt32LE(offset + 20));
|
||||
if (elementName === 'manifest') {
|
||||
const attributeStart = buffer.readUInt16LE(offset + 24);
|
||||
const attributeSize = buffer.readUInt16LE(offset + 26);
|
||||
const attributeCount = buffer.readUInt16LE(offset + 28);
|
||||
const attributesOffset = offset + 16 + attributeStart;
|
||||
if (
|
||||
attributeSize < 20 ||
|
||||
attributesOffset + attributeSize * attributeCount > offset + chunkSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = 0; index < attributeCount; index += 1) {
|
||||
const attributeOffset = attributesOffset + index * attributeSize;
|
||||
const name = strings.stringAt(buffer.readUInt32LE(attributeOffset + 4));
|
||||
if (name !== 'versionCode') continue;
|
||||
const valueType = buffer[attributeOffset + 15];
|
||||
if (valueType === TYPE_INT_DEC || valueType === TYPE_INT_HEX) {
|
||||
return buffer.readUInt32LE(attributeOffset + 16);
|
||||
}
|
||||
const rawValue = strings.stringAt(buffer.readUInt32LE(attributeOffset + 8));
|
||||
if (rawValue === null) return null;
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the installed version without extracting the APK or running Android tooling. */
|
||||
export async function readApkVersionCode(apkPath: string): Promise<number | null> {
|
||||
const manifest = await readZipEntry(apkPath, MANIFEST_ENTRY);
|
||||
return manifest ? parseAndroidManifestVersionCode(manifest) : null;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client';
|
||||
import { BRIDGE_CONTEXT_KEY } from './types';
|
||||
|
||||
const EXTENSION_ID = 'a'.repeat(64);
|
||||
const APK_BASE64 = 'QVBLLUJZVEVT';
|
||||
const source = {
|
||||
fingerprint: 'sha-1',
|
||||
loadApkBase64: async () => APK_BASE64,
|
||||
sourceId: 'source-1',
|
||||
};
|
||||
|
||||
interface Recorded {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stubFetch(responder: (call: Recorded, index: number) => Response): {
|
||||
fetchImpl: typeof fetch;
|
||||
calls: Recorded[];
|
||||
} {
|
||||
const calls: Recorded[] = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const call: Recorded = {
|
||||
url: String(input),
|
||||
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {},
|
||||
};
|
||||
calls.push(call);
|
||||
return responder(call, calls.length - 1);
|
||||
}) as typeof fetch;
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, extensionId?: string): Response {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (extensionId) headers.set('x-mangatan-extension-id', extensionId);
|
||||
return new Response(JSON.stringify(body), { status: 200, headers });
|
||||
}
|
||||
|
||||
test('isReady requires every capability the client depends on', async () => {
|
||||
const ready = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: stubFetch(() =>
|
||||
jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
||||
).fetchImpl,
|
||||
});
|
||||
assert.equal(await ready.isReady(), true);
|
||||
|
||||
const partial = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: stubFetch(() => jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true }))
|
||||
.fetchImpl,
|
||||
});
|
||||
assert.equal(await partial.isReady(), false);
|
||||
});
|
||||
|
||||
test('isReady caps the probe at the deadline the caller passes', async () => {
|
||||
// A bridge that accepts the socket and then stalls: only the abort ends it.
|
||||
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
await new Promise((resolve) => init?.signal?.addEventListener('abort', resolve));
|
||||
throw new Error('aborted');
|
||||
}) as typeof fetch;
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const started = Date.now();
|
||||
assert.equal(await client.isReady(50), false);
|
||||
// Well under the 5s default, so the per-call deadline is what applied.
|
||||
assert.ok(Date.now() - started < 1000, 'probe outlived the caller deadline');
|
||||
});
|
||||
|
||||
test('isReady reports false instead of throwing when the bridge is down', async () => {
|
||||
const client = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as typeof fetch,
|
||||
});
|
||||
assert.equal(await client.isReady(), false);
|
||||
});
|
||||
|
||||
test('getVideoList posts the APK and episode url with a bridge context preference', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ videoUrl: 'http://x/video/t' }]));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9/', fetchImpl });
|
||||
|
||||
const videos = await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
|
||||
assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
|
||||
assert.equal(calls[0]?.body.method, 'getVideoList');
|
||||
assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
|
||||
assert.equal(calls[0]?.body.data, APK_BASE64);
|
||||
assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
|
||||
assert.equal(videos.length, 1);
|
||||
});
|
||||
|
||||
test('a cached extension id replaces the APK upload on later calls', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls[0]?.body.data, APK_BASE64);
|
||||
assert.equal(calls[0]?.body.extensionId, undefined);
|
||||
assert.equal(calls[1]?.body.data, undefined);
|
||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||
});
|
||||
|
||||
test('an upgraded APK re-uploads instead of reusing the previous extension id', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
// Same source id, new build in the same file: the id cache must miss.
|
||||
const upgraded = { ...source, fingerprint: 'sha-2', loadApkBase64: async () => 'TkVXLUFQSw==' };
|
||||
await client.getVideoList(upgraded, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls[1]?.body.extensionId, undefined);
|
||||
assert.equal(calls[1]?.body.data, 'TkVXLUFQSw==');
|
||||
});
|
||||
|
||||
test('a 409 re-uploads the APK once and succeeds', async () => {
|
||||
const { fetchImpl, calls } = stubFetch((call, index) => {
|
||||
if (index === 0) return jsonResponse([], EXTENSION_ID);
|
||||
// Cache evicted: reject the id-only call, accept the re-upload.
|
||||
if (call.body.extensionId !== undefined) return new Response('', { status: 409 });
|
||||
return jsonResponse([{ videoUrl: 'http://x/video/t' }], EXTENSION_ID);
|
||||
});
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
const videos = await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||
assert.equal(calls[2]?.body.data, APK_BASE64);
|
||||
assert.equal(videos.length, 1);
|
||||
});
|
||||
|
||||
test('an error body on a 200 response raises BridgeExtensionError with the code', async () => {
|
||||
const { fetchImpl } = stubFetch(() => jsonResponse({ error: 'Cloudflare challenge', code: 403 }));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => client.getVideoList(source, 'https://origin.example/ep/1'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof BridgeExtensionError);
|
||||
assert.equal(error.code, 403);
|
||||
assert.match(error.message, /Cloudflare challenge/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('searchAnime sends a 1-based page and returns the page payload', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() =>
|
||||
jsonResponse({ animes: [{ title: 'Example' }], hasNextPage: true }),
|
||||
);
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const page = await client.searchAnime(source, 'example');
|
||||
|
||||
assert.equal(calls[0]?.body.method, 'getSearchAnime');
|
||||
assert.equal(calls[0]?.body.page, 1);
|
||||
assert.equal(calls[0]?.body.search, 'example');
|
||||
assert.deepEqual(calls[0]?.body.filterList, []);
|
||||
assert.equal(page.hasNextPage, true);
|
||||
assert.equal(page.animes?.length, 1);
|
||||
});
|
||||
|
||||
test('getEpisodeList wraps the anime url in animeData', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const episodes = await client.getEpisodeList(source, 'https://origin.example/anime/1');
|
||||
|
||||
assert.equal(calls[0]?.body.method, 'getEpisodeList');
|
||||
assert.deepEqual(calls[0]?.body.animeData, { url: 'https://origin.example/anime/1' });
|
||||
assert.equal(episodes[0]?.name, 'Episode 1');
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
import { BRIDGE_CONTEXT_KEY } from './types';
|
||||
import type {
|
||||
BridgeAnime,
|
||||
BridgeAnimePage,
|
||||
BridgeCapabilities,
|
||||
BridgeEpisode,
|
||||
BridgePreference,
|
||||
BridgeSourceDescriptor,
|
||||
BridgeVideo,
|
||||
} from './types';
|
||||
|
||||
const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
|
||||
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface BridgeSource {
|
||||
/**
|
||||
* Identity of the APK's contents. Keys the extension-id cache, so an upgraded
|
||||
* APK is re-uploaded instead of reusing the previous build's id.
|
||||
*/
|
||||
fingerprint: string;
|
||||
/**
|
||||
* Reads and base64-encodes the APK. Called only when the bridge actually
|
||||
* needs the bytes, so multi-megabyte payloads are not held on the heap.
|
||||
*/
|
||||
loadApkBase64: () => Promise<string>;
|
||||
/** Selects one source inside a multi-source (SourceFactory) APK. */
|
||||
sourceId?: string;
|
||||
preferences?: BridgePreference[];
|
||||
}
|
||||
|
||||
export interface BridgeClientOptions {
|
||||
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
|
||||
baseUrl: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Per-request deadline. Node's `fetch` has none, so a sidecar that accepts
|
||||
* the socket and then stalls would leave every call pending forever.
|
||||
*/
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Extension calls can be slow (a source may scrape several pages). */
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** The readiness probe is a local health check; it should answer at once. */
|
||||
const CAPABILITIES_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The bridge reports extension failures as HTTP 200 with an error body. */
|
||||
export class BridgeExtensionError extends Error {
|
||||
readonly code?: number;
|
||||
constructor(message: string, code?: number) {
|
||||
super(message);
|
||||
this.name = 'BridgeExtensionError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the M-Extension-Server `/dalvik` RPC endpoint.
|
||||
*
|
||||
* The server caches uploaded APKs and returns a content hash, letting
|
||||
* subsequent calls send that id instead of re-uploading megabytes of base64.
|
||||
* A 409 means the cache was evicted, so the APK is resent once.
|
||||
*/
|
||||
export class AnimeBridgeClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly extensionIds = new Map<string, string>();
|
||||
|
||||
constructor(options: BridgeClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeoutMs` lets a caller with its own deadline (the readiness loop) cap the
|
||||
* probe below the default, so a short readiness budget is actually honored.
|
||||
*/
|
||||
async getCapabilities(timeoutMs = CAPABILITIES_TIMEOUT_MS): Promise<BridgeCapabilities> {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
|
||||
signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, this.requestTimeoutMs))),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
|
||||
}
|
||||
return (await response.json()) as BridgeCapabilities;
|
||||
}
|
||||
|
||||
/** True once the bridge is up and reports the features this client needs. */
|
||||
async isReady(timeoutMs?: number): Promise<boolean> {
|
||||
try {
|
||||
const capabilities = await this.getCapabilities(timeoutMs);
|
||||
return (
|
||||
capabilities.mangatanMihonBridge === 1 &&
|
||||
capabilities.sourceFactory === true &&
|
||||
capabilities.preferenceCallbacks === true
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async searchAnime(
|
||||
source: BridgeSource,
|
||||
query: string,
|
||||
page = 1,
|
||||
filterList: unknown[] = [],
|
||||
): Promise<BridgeAnimePage> {
|
||||
return this.call<BridgeAnimePage>(source, 'getSearchAnime', {
|
||||
page,
|
||||
search: query,
|
||||
filterList,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sources an extension APK provides. A single APK may expose many
|
||||
* (a SourceFactory), so this is how a package becomes selectable entries.
|
||||
*/
|
||||
async listAnimeSources(source: BridgeSource): Promise<BridgeSourceDescriptor[]> {
|
||||
return this.call<BridgeSourceDescriptor[]>(source, 'sourcesAnime', {});
|
||||
}
|
||||
|
||||
/** The extension's own settings schema, with current values. */
|
||||
async getSourcePreferences(source: BridgeSource): Promise<BridgePreference[]> {
|
||||
return this.call<BridgePreference[]>(source, 'preferencesAnime', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a preference change. The whole array is sent back with the edited
|
||||
* entry, and `changedPreferenceKey` tells the extension which one moved so it
|
||||
* can react (the Jellyfin source logs in when the address or password lands).
|
||||
* Returns the extension's refreshed schema.
|
||||
*/
|
||||
async setSourcePreference(
|
||||
source: BridgeSource,
|
||||
changedPreferenceKey: string,
|
||||
): Promise<BridgePreference[]> {
|
||||
return this.call<BridgePreference[]>(source, 'setPreferenceAnime', {}, changedPreferenceKey);
|
||||
}
|
||||
|
||||
/** Full metadata for one anime: description, cover art, genres, status. */
|
||||
async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise<BridgeAnime> {
|
||||
return this.call<BridgeAnime>(source, 'getDetailsAnime', {
|
||||
animeData: { url: animeUrl },
|
||||
});
|
||||
}
|
||||
|
||||
async getPopularAnime(source: BridgeSource, page = 1): Promise<BridgeAnimePage> {
|
||||
return this.call<BridgeAnimePage>(source, 'getPopularAnime', { page });
|
||||
}
|
||||
|
||||
async getEpisodeList(source: BridgeSource, animeUrl: string): Promise<BridgeEpisode[]> {
|
||||
return this.call<BridgeEpisode[]>(source, 'getEpisodeList', {
|
||||
animeData: { url: animeUrl },
|
||||
});
|
||||
}
|
||||
|
||||
async getVideoList(source: BridgeSource, episodeUrl: string): Promise<BridgeVideo[]> {
|
||||
return this.call<BridgeVideo[]>(source, 'getVideoList', {
|
||||
episodeData: { url: episodeUrl },
|
||||
});
|
||||
}
|
||||
|
||||
private buildPreferences(
|
||||
source: BridgeSource,
|
||||
changedPreferenceKey?: string,
|
||||
): BridgePreference[] {
|
||||
const context: BridgePreference = { key: BRIDGE_CONTEXT_KEY };
|
||||
if (source.sourceId !== undefined) context.sourceId = source.sourceId;
|
||||
if (changedPreferenceKey !== undefined) context.changedPreferenceKey = changedPreferenceKey;
|
||||
return [...(source.preferences ?? []), context];
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
source: BridgeSource,
|
||||
method: string,
|
||||
extras: Record<string, unknown>,
|
||||
changedPreferenceKey?: string,
|
||||
): Promise<T> {
|
||||
// Keyed by APK contents, not by source id: an in-place upgrade keeps the
|
||||
// same source id, and reusing its cached extension id would silently run
|
||||
// the previous build (the bridge has no reason to answer 409).
|
||||
const cacheKey = `${source.fingerprint}:${source.sourceId ?? ''}`;
|
||||
const cachedId = this.extensionIds.get(cacheKey);
|
||||
|
||||
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
|
||||
if (response.status === 409 && cachedId !== undefined) {
|
||||
// Server evicted the cached APK; upload it again.
|
||||
this.extensionIds.delete(cacheKey);
|
||||
response = await this.post(method, extras, source, undefined, changedPreferenceKey);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anime bridge ${method} failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
|
||||
if (returnedId && EXTENSION_ID_PATTERN.test(returnedId)) {
|
||||
this.extensionIds.set(cacheKey, returnedId);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as T;
|
||||
assertNoExtensionError(body, method);
|
||||
return body;
|
||||
}
|
||||
|
||||
private async post(
|
||||
method: string,
|
||||
extras: Record<string, unknown>,
|
||||
source: BridgeSource,
|
||||
extensionId: string | undefined,
|
||||
changedPreferenceKey?: string,
|
||||
): Promise<Response> {
|
||||
const payload: Record<string, unknown> = {
|
||||
method,
|
||||
...extras,
|
||||
preferences: this.buildPreferences(source, changedPreferenceKey),
|
||||
...(extensionId === undefined ? { data: await source.loadApkBase64() } : { extensionId }),
|
||||
};
|
||||
|
||||
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoExtensionError(body: unknown, method: string): void {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
|
||||
const error = (body as { error?: unknown }).error;
|
||||
if (typeof error !== 'string') return;
|
||||
const code = (body as { code?: unknown }).code;
|
||||
throw new BridgeExtensionError(
|
||||
`Anime bridge ${method} failed: ${error}`,
|
||||
typeof code === 'number' ? code : undefined,
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildAnimeStreamMetadata,
|
||||
buildAnimeStreamStatsPath,
|
||||
buildStreamDisplayTitle,
|
||||
splitEpisodeLabel,
|
||||
splitSeasonFromTitle,
|
||||
} from './episode-metadata';
|
||||
|
||||
test('splitSeasonFromTitle pulls a trailing season marker off the title', () => {
|
||||
assert.deepEqual(splitSeasonFromTitle('Mushoku Tensei: Jobless Reincarnation Season 3'), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
season: 3,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('Spy x Family 2nd Season'), {
|
||||
title: 'Spy x Family',
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('Bocchi the Rock! S2'), {
|
||||
title: 'Bocchi the Rock!',
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('シャングリラ・フロンティア 第2期'), {
|
||||
title: 'シャングリラ・フロンティア',
|
||||
season: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('splitSeasonFromTitle leaves a title without a trailing marker alone', () => {
|
||||
assert.deepEqual(splitSeasonFromTitle('My Teen Romantic Comedy SNAFU Climax!'), {
|
||||
title: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
season: null,
|
||||
});
|
||||
// "Season" inside the name is not a season marker.
|
||||
assert.deepEqual(splitSeasonFromTitle('A Season of Snow and Ash'), {
|
||||
title: 'A Season of Snow and Ash',
|
||||
season: null,
|
||||
});
|
||||
// Nothing would be left of the title, so the marker is not a marker.
|
||||
assert.deepEqual(splitSeasonFromTitle('Season 2'), { title: 'Season 2', season: null });
|
||||
});
|
||||
|
||||
test('splitEpisodeLabel reads the number and the episode name', () => {
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 4'), { number: 4, title: null });
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 10: Gallantly, Shizuka Hiratsuka Moves Forward.'), {
|
||||
number: 10,
|
||||
title: 'Gallantly, Shizuka Hiratsuka Moves Forward.',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel('Ep. 7 - The Long Road'), {
|
||||
number: 7,
|
||||
title: 'The Long Road',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel('第12話 決戦'), { number: 12, title: '決戦' });
|
||||
assert.deepEqual(splitEpisodeLabel('5. Homecoming'), { number: 5, title: 'Homecoming' });
|
||||
assert.deepEqual(splitEpisodeLabel('13'), { number: 13, title: null });
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 6.5'), { number: 6.5, title: null });
|
||||
});
|
||||
|
||||
test('splitEpisodeLabel keeps a label that carries no number as a name', () => {
|
||||
assert.deepEqual(splitEpisodeLabel('Movie'), { number: null, title: 'Movie' });
|
||||
assert.deepEqual(splitEpisodeLabel('OVA - Beach Episode'), {
|
||||
number: null,
|
||||
title: 'OVA - Beach Episode',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel(''), { number: null, title: null });
|
||||
});
|
||||
|
||||
test('buildStreamDisplayTitle emits a form guessit and the jimaku parser both read', () => {
|
||||
assert.equal(
|
||||
buildStreamDisplayTitle('Mushoku Tensei: Jobless Reincarnation', 3, 4, null),
|
||||
'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
);
|
||||
assert.equal(
|
||||
buildStreamDisplayTitle('My Teen Romantic Comedy SNAFU Climax!', null, 10, 'Gallantly'),
|
||||
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
|
||||
);
|
||||
assert.equal(buildStreamDisplayTitle('Some Movie', null, null, null), 'Some Movie');
|
||||
});
|
||||
|
||||
test('buildAnimeStreamStatsPath is stable across playbacks of the same episode', () => {
|
||||
const first = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
|
||||
const second = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
|
||||
assert.equal(first, second);
|
||||
assert.notEqual(first, buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-5'));
|
||||
assert.match(first, /^animebrowser:\/\//);
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata resolves the browser strings into fields', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '9001',
|
||||
animeUrl: '/anime/mushoku',
|
||||
animeTitle: 'Mushoku Tensei: Jobless Reincarnation Season 3',
|
||||
episodeUrl: '/watch/ep-4',
|
||||
episodeName: 'Episode 4',
|
||||
episodeNumber: 4,
|
||||
mediaPath: 'http://127.0.0.1:41234/video/abc123.m3u8',
|
||||
});
|
||||
|
||||
assert.equal(metadata.seriesTitle, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
assert.equal(metadata.seasonNumber, 3);
|
||||
assert.equal(metadata.episodeNumber, 4);
|
||||
assert.equal(metadata.episodeTitle, null);
|
||||
assert.equal(metadata.displayTitle, 'Mushoku Tensei: Jobless Reincarnation S03E04');
|
||||
assert.equal(metadata.mediaPath, 'http://127.0.0.1:41234/video/abc123.m3u8');
|
||||
assert.equal(
|
||||
metadata.statsPath,
|
||||
buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4'),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata prefers the extension episode number over the label', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '1',
|
||||
animeUrl: '/a',
|
||||
animeTitle: 'Show',
|
||||
episodeUrl: '/e',
|
||||
episodeName: 'Finale',
|
||||
episodeNumber: 24,
|
||||
mediaPath: 'http://host/x.m3u8',
|
||||
});
|
||||
assert.equal(metadata.episodeNumber, 24);
|
||||
assert.equal(metadata.episodeTitle, 'Finale');
|
||||
assert.equal(metadata.displayTitle, 'Show E24 - Finale');
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata falls back to the label when the source reports no number', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '1',
|
||||
animeUrl: '/a',
|
||||
animeTitle: 'Show 2nd Season',
|
||||
episodeUrl: '/e',
|
||||
episodeName: 'Episode 3: Rain',
|
||||
episodeNumber: null,
|
||||
mediaPath: 'http://host/x.m3u8',
|
||||
});
|
||||
assert.equal(metadata.seasonNumber, 2);
|
||||
assert.equal(metadata.episodeNumber, 3);
|
||||
assert.equal(metadata.displayTitle, 'Show S02E03 - Rain');
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Structured metadata for a streamed episode.
|
||||
*
|
||||
* Extensions hand us two free-form strings — an anime title that usually
|
||||
* carries the season ("… Season 3") and an episode label that usually carries
|
||||
* the number ("Episode 4: …"). Everything downstream (stats grouping, AniList,
|
||||
* the subtitle modals) wants those as separate fields, so they are split once
|
||||
* here rather than re-parsed out of the mpv title by each consumer.
|
||||
*/
|
||||
|
||||
/** Where a stream came from, resolved into the fields consumers actually want. */
|
||||
export interface AnimeStreamMetadata {
|
||||
sourceId: string;
|
||||
animeUrl: string;
|
||||
episodeUrl: string;
|
||||
/** The URL handed to mpv. Matches what mpv reports as `path`. */
|
||||
mediaPath: string;
|
||||
/**
|
||||
* Stable identity for this episode. The stream URL carries a per-playback
|
||||
* proxy port and token, so it cannot be the key stats stores.
|
||||
*/
|
||||
statsPath: string;
|
||||
/** Series name with the season suffix removed. */
|
||||
seriesTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
/** The episode's own name, or null when the label was only a number. */
|
||||
episodeTitle: string | null;
|
||||
/** Shown by mpv, and the fallback every string parser sees. */
|
||||
displayTitle: string;
|
||||
}
|
||||
|
||||
export interface AnimeStreamMetadataInput {
|
||||
sourceId: string;
|
||||
animeUrl: string;
|
||||
animeTitle: string;
|
||||
episodeUrl: string;
|
||||
episodeName: string;
|
||||
/** Extension-reported number; trusted over anything parsed from the label. */
|
||||
episodeNumber: number | null;
|
||||
/** The URL playback actually uses, after proxy rewriting. */
|
||||
mediaPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Season suffixes, anchored to the end of the title so a "Season" that is part
|
||||
* of the name ("A Season of Snow") cannot be mistaken for one.
|
||||
*/
|
||||
const SEASON_SUFFIX_PATTERNS: RegExp[] = [
|
||||
/[\s:_-]+season\s*(\d{1,2})\s*$/i,
|
||||
/[\s:_-]+(\d{1,2})(?:st|nd|rd|th)\s+season\s*$/i,
|
||||
/[\s:_-]+s(\d{1,2})\s*$/i,
|
||||
/[\s:_-]*第\s*(\d{1,2})\s*期\s*$/,
|
||||
/[\s:_-]+(\d{1,2})\s*期\s*$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Episode labels, most specific first. The trailing group is the episode's own
|
||||
* name when the label carries one.
|
||||
*/
|
||||
const EPISODE_LABEL_PATTERNS: RegExp[] = [
|
||||
/^\s*(?:episodio|épisode|episode|ep|e)\s*[.#]?\s*(\d{1,4}(?:\.\d+)?)\s*(?:[:\-–—.)]+\s*(.*))?$/i,
|
||||
/^\s*第\s*(\d{1,4})\s*話\s*(?:[:\-–—]+\s*)?(.*)$/,
|
||||
/^\s*(\d{1,4}(?:\.\d+)?)\s*[:\-–—.)]+\s*(.*)$/,
|
||||
/^\s*(\d{1,4}(?:\.\d+)?)\s*$/,
|
||||
];
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims separators a split left dangling on either end. `.` is deliberately not
|
||||
* one of them: an episode name often ends in a full stop that belongs to it.
|
||||
*/
|
||||
function trimSeparators(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.replace(/^[\s:_\-–—]+/, '')
|
||||
.replace(/[\s:_\-–—]+$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toEpisodeNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a trailing season marker off an anime title.
|
||||
*
|
||||
* "Mushoku Tensei: Jobless Reincarnation Season 3" becomes the series plus
|
||||
* season 3, which is what both AniList and the stats grouping key want. A title
|
||||
* with no marker is returned unchanged with a null season — season 1 is *not*
|
||||
* assumed, because "unknown" and "one" behave differently when grouping.
|
||||
*/
|
||||
export function splitSeasonFromTitle(animeTitle: string): {
|
||||
title: string;
|
||||
season: number | null;
|
||||
} {
|
||||
const normalized = collapseWhitespace(animeTitle);
|
||||
for (const pattern of SEASON_SUFFIX_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (!match || match.index === undefined) continue;
|
||||
const season = Number.parseInt(match[1]!, 10);
|
||||
if (!Number.isInteger(season) || season <= 0) continue;
|
||||
const title = trimSeparators(normalized.slice(0, match.index));
|
||||
// A title that is *only* a season marker is not a title; keep the original.
|
||||
if (!title) continue;
|
||||
return { title, season };
|
||||
}
|
||||
return { title: normalized, season: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an episode label into its number and its own name.
|
||||
*
|
||||
* Sources are inconsistent here: "Episode 4", "4. Title", "第4話 タイトル" and a
|
||||
* bare "4" all show up. A label that matches nothing is treated as a pure
|
||||
* episode name, which is right for movies and specials.
|
||||
*/
|
||||
export function splitEpisodeLabel(episodeName: string): {
|
||||
number: number | null;
|
||||
title: string | null;
|
||||
} {
|
||||
const normalized = collapseWhitespace(episodeName);
|
||||
if (!normalized) return { number: null, title: null };
|
||||
|
||||
for (const pattern of EPISODE_LABEL_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (!match) continue;
|
||||
const parsed = Number.parseFloat(match[1]!);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) continue;
|
||||
const title = trimSeparators(match[2] ?? '');
|
||||
return { number: parsed, title: title || null };
|
||||
}
|
||||
|
||||
return { number: null, title: normalized };
|
||||
}
|
||||
|
||||
function formatEpisodePart(value: number): string {
|
||||
return Number.isInteger(value) ? String(value).padStart(2, '0') : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title mpv shows.
|
||||
*
|
||||
* `SxxEyy` is not just for looks: it is the one form both guessit and
|
||||
* SubMiner's own filename parser read reliably, so any consumer that only ever
|
||||
* sees the title string still lands on the right series and episode.
|
||||
*/
|
||||
export function buildStreamDisplayTitle(
|
||||
seriesTitle: string,
|
||||
season: number | null,
|
||||
episode: number | null,
|
||||
episodeTitle: string | null,
|
||||
): string {
|
||||
const parts: string[] = [seriesTitle];
|
||||
if (episode !== null) {
|
||||
parts.push(
|
||||
season !== null
|
||||
? `S${String(season).padStart(2, '0')}E${formatEpisodePart(episode)}`
|
||||
: `E${formatEpisodePart(episode)}`,
|
||||
);
|
||||
} else if (season !== null) {
|
||||
parts.push(`S${String(season).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
const head = parts.join(' ');
|
||||
return episodeTitle ? `${head} - ${episodeTitle}` : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-episode identity that survives across playbacks.
|
||||
*
|
||||
* The stream URL points at the strip proxy, whose port and token are minted per
|
||||
* playback, so keying stats on it makes every rewatch a new video. The source's
|
||||
* own episode url is stable, so that is what stats records instead — with the
|
||||
* real URL kept as an alias so mpv's path change still finds the row.
|
||||
*/
|
||||
export function buildAnimeStreamStatsPath(
|
||||
sourceId: string,
|
||||
animeUrl: string,
|
||||
episodeUrl: string,
|
||||
): string {
|
||||
const source = encodeURIComponent(sourceId || 'unknown');
|
||||
const anime = encodeURIComponent(animeUrl || 'unknown');
|
||||
const episode = encodeURIComponent(episodeUrl || 'unknown');
|
||||
return `animebrowser://${source}/${anime}/${episode}`;
|
||||
}
|
||||
|
||||
export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): AnimeStreamMetadata {
|
||||
const { title: seriesTitle, season } = splitSeasonFromTitle(input.animeTitle);
|
||||
const label = splitEpisodeLabel(input.episodeName);
|
||||
const episodeNumber = toEpisodeNumber(input.episodeNumber) ?? label.number;
|
||||
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
|
||||
|
||||
return {
|
||||
sourceId: input.sourceId,
|
||||
animeUrl: input.animeUrl,
|
||||
episodeUrl: input.episodeUrl,
|
||||
mediaPath: input.mediaPath,
|
||||
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
|
||||
seriesTitle,
|
||||
seasonNumber: season,
|
||||
episodeNumber,
|
||||
episodeTitle: label.title,
|
||||
// A source that gave us neither a number nor a name leaves the series title
|
||||
// alone rather than showing an empty suffix.
|
||||
displayTitle: displayTitle || collapseWhitespace(input.animeTitle),
|
||||
};
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { installExtension, looksLikeApk, removeExtension } from './extension-installer';
|
||||
import type { RepoExtension } from './extension-repo';
|
||||
|
||||
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
|
||||
|
||||
function apkBytes(payload = 'APK-BODY'): Uint8Array {
|
||||
// APKs are zip archives, so they start with the PK local-file-header magic.
|
||||
return new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...new TextEncoder().encode(payload)]);
|
||||
}
|
||||
|
||||
function repoExtension(overrides: Partial<RepoExtension> = {}): RepoExtension {
|
||||
return {
|
||||
pkg: PKG,
|
||||
name: 'Example Source',
|
||||
lang: 'all',
|
||||
version: '1.2.3',
|
||||
versionCode: 12,
|
||||
nsfw: false,
|
||||
apkUrl: 'https://repo.example/anime/apk/example.apk',
|
||||
iconUrl: 'https://repo.example/anime/icon/example.png',
|
||||
repoUrl: 'https://repo.example/anime/index.min.json',
|
||||
sourceNames: ['Example'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function respondWith(bytes: Uint8Array, headers: Record<string, string> = {}): typeof fetch {
|
||||
// Uint8Array is a valid Response body at runtime; the DOM lib types disagree.
|
||||
const body = bytes as unknown as BodyInit;
|
||||
return (async () => new Response(body, { status: 200, headers })) as typeof fetch;
|
||||
}
|
||||
|
||||
test('looksLikeApk accepts the zip magic and rejects anything else', () => {
|
||||
assert.equal(looksLikeApk(apkBytes()), true);
|
||||
assert.equal(looksLikeApk(new TextEncoder().encode('<!DOCTYPE html>')), false);
|
||||
assert.equal(looksLikeApk(new Uint8Array([])), false);
|
||||
});
|
||||
|
||||
test('installExtension writes the apk named after its package', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const target = await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
});
|
||||
|
||||
assert.equal(target, path.join(dir, `${PKG}.apk`));
|
||||
assert.match((await readFile(target)).toString(), /APK-BODY/);
|
||||
});
|
||||
|
||||
test('installing again replaces the previous version in place', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes('OLD')),
|
||||
});
|
||||
await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
|
||||
fetchImpl: respondWith(apkBytes('NEW')),
|
||||
});
|
||||
|
||||
const contents = (await readFile(path.join(dir, `${PKG}.apk`))).toString();
|
||||
assert.match(contents, /NEW/);
|
||||
assert.doesNotMatch(contents, /OLD/);
|
||||
});
|
||||
|
||||
test('the extensions directory is created when missing', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const nested = path.join(root, 'does', 'not', 'exist');
|
||||
await installExtension({
|
||||
extensionsDir: nested,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
});
|
||||
assert.equal(existsSync(path.join(nested, `${PKG}.apk`)), true);
|
||||
});
|
||||
|
||||
test('a non-ok response is reported with the extension name', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
|
||||
/Example Source.*404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a response that is not an apk is rejected rather than written', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
// A misconfigured repo commonly serves an HTML error page instead.
|
||||
const fetchImpl = respondWith(new TextEncoder().encode('<!DOCTYPE html><html>404</html>'));
|
||||
|
||||
await assert.rejects(
|
||||
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
|
||||
/did not download as an APK/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||
});
|
||||
|
||||
test('an oversized download is refused by the declared length', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = respondWith(apkBytes(), { 'content-length': '999999999' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
test('an oversized download is refused even when the length header lies', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = respondWith(apkBytes('x'.repeat(4096)), { 'content-length': '10' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||
});
|
||||
|
||||
test('the byte limit stops the read instead of buffering the whole body', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
let pushed = 0;
|
||||
// Endless body: if the limit were only checked after buffering, this hangs.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pushed += 1;
|
||||
controller.enqueue(new Uint8Array(512));
|
||||
},
|
||||
});
|
||||
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
// Only enough chunks to cross the limit were ever read.
|
||||
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
|
||||
});
|
||||
|
||||
test('a failed reader cancellation does not hide the size-limit error', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
let cancellationAttempted = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(1025));
|
||||
},
|
||||
async cancel() {
|
||||
cancellationAttempted = true;
|
||||
throw new Error('cancel failed');
|
||||
},
|
||||
});
|
||||
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
assert.ok(cancellationAttempted, 'the reader was never cancelled');
|
||||
});
|
||||
|
||||
test('a failed staged write preserves the installed apk and removes the partial file', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const target = path.join(dir, `${PKG}.apk`);
|
||||
await writeFile(target, apkBytes('OLD'));
|
||||
let stagedPath = '';
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
|
||||
fetchImpl: respondWith(apkBytes('NEW')),
|
||||
fileIo: {
|
||||
mkdir: (dirPath) => mkdir(dirPath, { recursive: true }),
|
||||
async writeFile(filePath, bytes) {
|
||||
stagedPath = filePath;
|
||||
await writeFile(filePath, bytes.subarray(0, 5));
|
||||
throw new Error('simulated disk write failure');
|
||||
},
|
||||
rename,
|
||||
removeFile: (filePath) => rm(filePath, { force: true }),
|
||||
},
|
||||
}),
|
||||
/simulated disk write failure/,
|
||||
);
|
||||
|
||||
assert.match((await readFile(target)).toString(), /OLD/);
|
||||
assert.notEqual(stagedPath, target);
|
||||
assert.equal(existsSync(stagedPath), false);
|
||||
});
|
||||
|
||||
test('a package name carrying path separators cannot escape the extensions dir', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const dir = path.join(root, 'extensions');
|
||||
const escaping = `eu.kanade.tachiyomi.animeextension${path.sep}..${path.sep}..${path.sep}pwned`;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ pkg: escaping }),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
}),
|
||||
/not a valid file name/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(root, 'pwned.apk')), false);
|
||||
});
|
||||
|
||||
test('removeExtension deletes the file and tolerates a missing one', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const file = path.join(dir, `${PKG}.apk`);
|
||||
await writeFile(file, 'x');
|
||||
|
||||
await removeExtension(dir, PKG);
|
||||
assert.equal(existsSync(file), false);
|
||||
await removeExtension(dir, PKG);
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { extensionFileName, type RepoExtension } from './extension-repo';
|
||||
|
||||
/**
|
||||
* Downloads extension APKs into the extensions directory.
|
||||
*
|
||||
* Only URLs that came from a repository index the user configured are ever
|
||||
* fetched; nothing here discovers or suggests sources.
|
||||
*/
|
||||
|
||||
export interface InstallExtensionOptions {
|
||||
extensionsDir: string;
|
||||
extension: RepoExtension;
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Guards against a mistyped repo serving something enormous. */
|
||||
maxBytes?: number;
|
||||
/** Cancels a stalled download; without it a hung repo blocks the install. */
|
||||
signal?: AbortSignal;
|
||||
/** Applied when no `signal` is given, so a download can never hang forever. */
|
||||
timeoutMs?: number;
|
||||
/** Injectable filesystem boundary for failure-path tests. */
|
||||
fileIo?: ExtensionInstallerFileIo;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallerFileIo {
|
||||
mkdir: (dir: string) => Promise<unknown>;
|
||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
rename: (from: string, to: string) => Promise<void>;
|
||||
removeFile: (filePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_IO: ExtensionInstallerFileIo = {
|
||||
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
||||
writeFile: (filePath, bytes) => writeFile(filePath, bytes),
|
||||
rename,
|
||||
removeFile: (filePath) => rm(filePath, { force: true }),
|
||||
};
|
||||
|
||||
/** APKs are a few MB; anything far past that is not an extension. */
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Generous enough for a large APK on a slow link, short of hanging forever. */
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
|
||||
|
||||
export function looksLikeApk(bytes: Uint8Array): boolean {
|
||||
return APK_MAGIC.every((byte, index) => bytes[index] === byte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download one extension into `extensionsDir`, replacing any previous version.
|
||||
*
|
||||
* The file is named after the package so an update overwrites in place rather
|
||||
* than leaving two versions for the bridge to load.
|
||||
*/
|
||||
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const fileIo = options.fileIo ?? DEFAULT_FILE_IO;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
const response = await fetchImpl(options.extension.apkUrl, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const declared = Number(response.headers.get('content-length') ?? '0');
|
||||
if (declared > maxBytes) {
|
||||
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
|
||||
const bytes = await readBounded(response, maxBytes, options.extension.name);
|
||||
if (!looksLikeApk(bytes)) {
|
||||
throw new Error(`${options.extension.name} did not download as an APK.`);
|
||||
}
|
||||
|
||||
await fileIo.mkdir(options.extensionsDir);
|
||||
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
|
||||
const staged = `${target}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fileIo.writeFile(staged, bytes);
|
||||
await fileIo.rename(staged, target);
|
||||
} finally {
|
||||
try {
|
||||
await fileIo.removeFile(staged);
|
||||
} catch {}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the body incrementally and stop the moment the limit is passed.
|
||||
*
|
||||
* Buffering first and measuring afterwards would let a repo that lies about
|
||||
* (or omits) `content-length` push an unbounded amount into memory before the
|
||||
* check ever runs.
|
||||
*/
|
||||
async function readBounded(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
name: string,
|
||||
): Promise<Uint8Array> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defence in depth against a repository index that smuggles path separators
|
||||
* into a package name: the write target must stay inside `extensionsDir`.
|
||||
*/
|
||||
function resolveTarget(extensionsDir: string, pkg: string): string {
|
||||
const root = path.resolve(extensionsDir);
|
||||
const target = path.resolve(root, extensionFileName(pkg));
|
||||
if (path.dirname(target) !== root) {
|
||||
throw new Error(`Refusing to install ${pkg}: the package name is not a valid file name.`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Delete an installed extension. Missing files are treated as already gone. */
|
||||
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
|
||||
await rm(resolveTarget(extensionsDir, pkg), { force: true });
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extensionFileName,
|
||||
fetchRepoCatalogue,
|
||||
fetchRepoIndex,
|
||||
isValidRepoUrl,
|
||||
parseRepoIndex,
|
||||
repoBaseUrl,
|
||||
} from './extension-repo';
|
||||
|
||||
const INDEX = 'https://repo.example/anime/index.min.json';
|
||||
|
||||
function animeEntry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
name: 'Aniyomi: Example Source',
|
||||
pkg: 'eu.kanade.tachiyomi.animeextension.all.example',
|
||||
apk: 'example-v1.2.3.apk',
|
||||
lang: 'all',
|
||||
code: 12,
|
||||
version: '1.2.3',
|
||||
nsfw: 0,
|
||||
sources: [{ name: 'Example', lang: 'en' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('any https url naming a json index is accepted', () => {
|
||||
assert.equal(isValidRepoUrl(INDEX), true);
|
||||
assert.equal(isValidRepoUrl(' ' + INDEX + ' '), true);
|
||||
// Repos are free to name the index; index.min.json is only a convention.
|
||||
assert.equal(isValidRepoUrl('https://repo.example/anime/index.json'), true);
|
||||
assert.equal(
|
||||
isValidRepoUrl('https://manatan-community.github.io/extensions/video.min.json'),
|
||||
true,
|
||||
);
|
||||
// Plain http would let a network attacker swap the APK list.
|
||||
assert.equal(isValidRepoUrl('http://repo.example/anime/index.min.json'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example/anime/'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example/index.min.json.txt'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example'), false);
|
||||
assert.equal(isValidRepoUrl(''), false);
|
||||
});
|
||||
|
||||
test('repoBaseUrl strips the index file name', () => {
|
||||
assert.equal(repoBaseUrl(INDEX), 'https://repo.example/anime');
|
||||
assert.equal(
|
||||
repoBaseUrl('https://manatan-community.github.io/extensions/video.min.json'),
|
||||
'https://manatan-community.github.io/extensions',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseRepoIndex builds apk and icon urls from the repo root', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
|
||||
|
||||
assert.equal(extension?.pkg, 'eu.kanade.tachiyomi.animeextension.all.example');
|
||||
assert.equal(extension?.apkUrl, 'https://repo.example/anime/apk/example-v1.2.3.apk');
|
||||
assert.equal(
|
||||
extension?.iconUrl,
|
||||
'https://repo.example/anime/icon/eu.kanade.tachiyomi.animeextension.all.example.png',
|
||||
);
|
||||
assert.equal(extension?.repoUrl, INDEX);
|
||||
assert.equal(extension?.versionCode, 12);
|
||||
assert.deepEqual(extension?.sourceNames, ['Example']);
|
||||
});
|
||||
|
||||
test('the Aniyomi name prefix is stripped', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
|
||||
assert.equal(extension?.name, 'Example Source');
|
||||
});
|
||||
|
||||
test('manga packages are excluded', () => {
|
||||
const entries = [animeEntry(), animeEntry({ pkg: 'eu.kanade.tachiyomi.extension.en.somemanga' })];
|
||||
const parsed = parseRepoIndex(INDEX, entries);
|
||||
assert.equal(parsed.length, 1);
|
||||
assert.match(parsed[0]!.pkg, /animeextension/);
|
||||
});
|
||||
|
||||
test('malformed entries are skipped rather than failing the repo', () => {
|
||||
const parsed = parseRepoIndex(INDEX, [
|
||||
null,
|
||||
'nonsense',
|
||||
animeEntry({ apk: undefined }),
|
||||
animeEntry({ pkg: undefined }),
|
||||
animeEntry(),
|
||||
]);
|
||||
assert.equal(parsed.length, 1);
|
||||
});
|
||||
|
||||
test('a package name that is not a plain identifier is rejected', () => {
|
||||
// The package name becomes the on-disk file name, and a repo index is
|
||||
// unauthenticated: path separators here would write outside the extensions
|
||||
// directory even though the prefix check passes.
|
||||
const parsed = parseRepoIndex(INDEX, [
|
||||
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension/../../../../etc/cron.d/x' }),
|
||||
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension\\..\\evil' }),
|
||||
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension.all.ok' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
parsed.map((extension) => extension.pkg),
|
||||
['eu.kanade.tachiyomi.animeextension.all.ok'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an apk file name with path characters is rejected', () => {
|
||||
const parsed = parseRepoIndex(INDEX, [animeEntry({ apk: '../../../etc/passwd' })]);
|
||||
assert.deepEqual(parsed, []);
|
||||
});
|
||||
|
||||
test('parseRepoIndex tolerates a non-array payload', () => {
|
||||
assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
|
||||
assert.deepEqual(parseRepoIndex(INDEX, null), []);
|
||||
});
|
||||
|
||||
test('missing optional fields fall back to safe defaults', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [
|
||||
{ pkg: 'eu.kanade.tachiyomi.animeextension.all.bare', apk: 'bare.apk' },
|
||||
]);
|
||||
assert.equal(extension?.name, 'eu.kanade.tachiyomi.animeextension.all.bare');
|
||||
assert.equal(extension?.lang, 'all');
|
||||
assert.equal(extension?.versionCode, 0);
|
||||
assert.equal(extension?.nsfw, false);
|
||||
assert.deepEqual(extension?.sourceNames, []);
|
||||
});
|
||||
|
||||
test('nsfw is read from the numeric flag', () => {
|
||||
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 1 })])[0]?.nsfw, true);
|
||||
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 0 })])[0]?.nsfw, false);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex rejects an invalid url before making a request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = (async () => {
|
||||
called = true;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
await assert.rejects(() => fetchRepoIndex('http://insecure/index.min.json', { fetchImpl }));
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex surfaces a non-ok response', async () => {
|
||||
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
await assert.rejects(() => fetchRepoIndex(INDEX, { fetchImpl }), /404/);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex applies a deadline when the caller supplies no signal', async () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
receivedSignal = init?.signal instanceof AbortSignal ? init.signal : undefined;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
await fetchRepoIndex(INDEX, { fetchImpl, timeoutMs: 50 });
|
||||
|
||||
assert.ok(receivedSignal, 'repository request should receive a deadline signal');
|
||||
});
|
||||
|
||||
test('fetchRepoCatalogue merges repos and keeps the highest version code', async () => {
|
||||
const second = 'https://other.example/anime/index.min.json';
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === INDEX) {
|
||||
return new Response(JSON.stringify([animeEntry({ code: 12, version: '1.2.3' })]));
|
||||
}
|
||||
return new Response(JSON.stringify([animeEntry({ code: 20, version: '2.0.0' })]));
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([INDEX, second], { fetchImpl });
|
||||
|
||||
assert.equal(catalogue.extensions.length, 1);
|
||||
assert.equal(catalogue.extensions[0]?.versionCode, 20);
|
||||
assert.equal(catalogue.extensions[0]?.repoUrl, second);
|
||||
assert.deepEqual(catalogue.failures, []);
|
||||
});
|
||||
|
||||
test('one failing repo does not hide the others', async () => {
|
||||
const broken = 'https://broken.example/anime/index.min.json';
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
if (String(input) === broken) throw new Error('ENOTFOUND');
|
||||
return new Response(JSON.stringify([animeEntry()]));
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([broken, INDEX], { fetchImpl });
|
||||
|
||||
assert.equal(catalogue.extensions.length, 1);
|
||||
assert.equal(catalogue.failures.length, 1);
|
||||
assert.equal(catalogue.failures[0]?.repoUrl, broken);
|
||||
assert.match(catalogue.failures[0]?.error ?? '', /ENOTFOUND/);
|
||||
});
|
||||
|
||||
test('an empty repo list yields an empty catalogue without any request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = (async () => {
|
||||
called = true;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([], { fetchImpl });
|
||||
assert.deepEqual(catalogue, { extensions: [], failures: [] });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('extensions are stored under their package name so updates replace in place', () => {
|
||||
assert.equal(
|
||||
extensionFileName('eu.kanade.tachiyomi.animeextension.all.example'),
|
||||
'eu.kanade.tachiyomi.animeextension.all.example.apk',
|
||||
);
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Client for Aniyomi-format extension repositories.
|
||||
*
|
||||
* SubMiner ships no repositories and performs no discovery. A repository only
|
||||
* exists once the user adds its index URL, and only extensions from those
|
||||
* repositories are ever listed or downloaded.
|
||||
*/
|
||||
|
||||
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
|
||||
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
|
||||
|
||||
/**
|
||||
* A package name becomes the on-disk APK file name, and a repository index is
|
||||
* unauthenticated content the user pointed us at. Only plain dotted identifiers
|
||||
* are accepted, so nothing in an index can carry `/` or `..` into a file path.
|
||||
*/
|
||||
const PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_.]+$/;
|
||||
|
||||
/** The APK file name is appended to the repo URL, so keep it a bare name. */
|
||||
const APK_FILE_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
|
||||
|
||||
/**
|
||||
* Repos are identified by their index URL. The file name is not fixed:
|
||||
* `index.min.json` is the Aniyomi convention, but repositories publish under
|
||||
* other names too (e.g. `video.min.json`), so only https and a `.json` file
|
||||
* name are required.
|
||||
*/
|
||||
const INDEX_URL_PATTERN = /^https:\/\/[^\s/]+(?:\/[^\s/]*)*\/[^\s/]+\.json$/;
|
||||
|
||||
export interface RepoExtension {
|
||||
/** Package name, the stable identity of an extension across versions. */
|
||||
pkg: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
version: string;
|
||||
/** Monotonic version code; the comparison basis for updates. */
|
||||
versionCode: number;
|
||||
nsfw: boolean;
|
||||
apkUrl: string;
|
||||
iconUrl: string;
|
||||
/** Index URL of the repo this came from. */
|
||||
repoUrl: string;
|
||||
/** Source names the package provides, when the index declares them. */
|
||||
sourceNames: string[];
|
||||
}
|
||||
|
||||
/** `true` when `url` is a usable Aniyomi index URL. */
|
||||
export function isValidRepoUrl(url: string): boolean {
|
||||
return INDEX_URL_PATTERN.test(url.trim());
|
||||
}
|
||||
|
||||
/** Strip the index file name to get the repo root. */
|
||||
export function repoBaseUrl(indexUrl: string): string {
|
||||
return indexUrl.trim().replace(/\/[^/]*$/, '');
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
name?: unknown;
|
||||
pkg?: unknown;
|
||||
apk?: unknown;
|
||||
lang?: unknown;
|
||||
code?: unknown;
|
||||
version?: unknown;
|
||||
nsfw?: unknown;
|
||||
sources?: unknown;
|
||||
}
|
||||
|
||||
function readSourceNames(sources: unknown): string[] {
|
||||
if (!Array.isArray(sources)) return [];
|
||||
return sources
|
||||
.map((source) =>
|
||||
source !== null && typeof source === 'object'
|
||||
? (source as { name?: unknown }).name
|
||||
: undefined,
|
||||
)
|
||||
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an index payload into anime extensions.
|
||||
*
|
||||
* Entries that are malformed, or that are manga rather than anime packages,
|
||||
* are skipped rather than failing the whole repo.
|
||||
*/
|
||||
export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtension[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
const base = repoBaseUrl(indexUrl);
|
||||
|
||||
const extensions: RepoExtension[] = [];
|
||||
for (const raw of payload as RawEntry[]) {
|
||||
if (raw === null || typeof raw !== 'object') continue;
|
||||
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
|
||||
const apk = typeof raw.apk === 'string' ? raw.apk : '';
|
||||
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || !PACKAGE_NAME_PATTERN.test(pkg)) continue;
|
||||
if (apk.length === 0 || !APK_FILE_NAME_PATTERN.test(apk)) continue;
|
||||
|
||||
const versionCode = Number(raw.code);
|
||||
extensions.push({
|
||||
pkg,
|
||||
// Repo entries are prefixed "Aniyomi: "; the app supplies its own context.
|
||||
name: (typeof raw.name === 'string' ? raw.name : pkg).replace(/^Aniyomi:\s*/, ''),
|
||||
lang: typeof raw.lang === 'string' ? raw.lang : 'all',
|
||||
version: typeof raw.version === 'string' ? raw.version : '0',
|
||||
versionCode: Number.isFinite(versionCode) ? versionCode : 0,
|
||||
nsfw: Number(raw.nsfw) === 1,
|
||||
apkUrl: `${base}/apk/${apk}`,
|
||||
iconUrl: `${base}/icon/${pkg}.png`,
|
||||
repoUrl: indexUrl,
|
||||
sourceNames: readSourceNames(raw.sources),
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
export interface FetchRepoOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
/** Applied when no signal is supplied, so one stalled repo cannot block the catalogue. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_REPO_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Fetch and parse one repository index. */
|
||||
export async function fetchRepoIndex(
|
||||
indexUrl: string,
|
||||
options: FetchRepoOptions = {},
|
||||
): Promise<RepoExtension[]> {
|
||||
if (!isValidRepoUrl(indexUrl)) {
|
||||
throw new Error(`Not a valid repository index URL: ${indexUrl}`);
|
||||
}
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const signal =
|
||||
options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REPO_TIMEOUT_MS);
|
||||
const response = await fetchImpl(indexUrl.trim(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
|
||||
}
|
||||
return parseRepoIndex(indexUrl, await response.json());
|
||||
}
|
||||
|
||||
export interface RepoFetchFailure {
|
||||
repoUrl: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface RepoCatalogue {
|
||||
extensions: RepoExtension[];
|
||||
failures: RepoFetchFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every configured repository.
|
||||
*
|
||||
* When two repos publish the same package, the higher version code wins, so a
|
||||
* user's preferred repo ordering does not silently pin an older build.
|
||||
*/
|
||||
export async function fetchRepoCatalogue(
|
||||
indexUrls: string[],
|
||||
options: FetchRepoOptions = {},
|
||||
): Promise<RepoCatalogue> {
|
||||
const failures: RepoFetchFailure[] = [];
|
||||
const byPackage = new Map<string, RepoExtension>();
|
||||
|
||||
const results = await Promise.all(
|
||||
indexUrls.map(async (indexUrl) => {
|
||||
try {
|
||||
return { indexUrl, extensions: await fetchRepoIndex(indexUrl, options) };
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
repoUrl: indexUrl,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { indexUrl, extensions: [] as RepoExtension[] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { extensions } of results) {
|
||||
for (const extension of extensions) {
|
||||
const existing = byPackage.get(extension.pkg);
|
||||
if (!existing || extension.versionCode > existing.versionCode) {
|
||||
byPackage.set(extension.pkg, extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extensions: [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/** File name an extension is stored under, so updates replace in place. */
|
||||
export function extensionFileName(pkg: string): string {
|
||||
return `${pkg}.apk`;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
toBridgeSource,
|
||||
toInstalledExtensionViews,
|
||||
type ExtensionSource,
|
||||
type InstalledExtension,
|
||||
} from './extension-store';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
|
||||
async function makeExtensionDir(files: Record<string, string>): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-ext-'));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
await writeFile(path.join(dir, name), contents);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function fakeClient(
|
||||
impl: (source: { fingerprint: string }) => Promise<unknown[]>,
|
||||
): AnimeBridgeClient {
|
||||
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
|
||||
}
|
||||
|
||||
test('readInstalledExtensions fingerprints apks without holding their bytes', async () => {
|
||||
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
|
||||
const extensions = await readInstalledExtensions(dir);
|
||||
|
||||
assert.equal(extensions.length, 1);
|
||||
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
||||
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
|
||||
assert.equal(extensions[0]?.versionCode, null);
|
||||
});
|
||||
|
||||
test('the fingerprint changes when an apk is replaced in place', async () => {
|
||||
const dir = await makeExtensionDir({ 'my-source.apk': 'V1' });
|
||||
const before = (await readInstalledExtensions(dir))[0]?.sha256;
|
||||
await writeFile(path.join(dir, 'my-source.apk'), 'V2');
|
||||
const after = (await readInstalledExtensions(dir))[0]?.sha256;
|
||||
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
|
||||
test('toBridgeSource reads the apk only when the bridge asks for it', async () => {
|
||||
const dir = await makeExtensionDir({ 'lazy.apk': 'APK-BYTES' });
|
||||
const extension = (await readInstalledExtensions(dir))[0]!;
|
||||
|
||||
const bridgeSource = toBridgeSource(extension);
|
||||
assert.equal(bridgeSource.fingerprint, extension.sha256);
|
||||
assert.equal(Buffer.from(await bridgeSource.loadApkBase64(), 'base64').toString(), 'APK-BYTES');
|
||||
});
|
||||
|
||||
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
|
||||
const dir = await makeExtensionDir({ 'a.apk': 'A', 'notes.txt': 'x', 'b.APK': 'B' });
|
||||
await mkdir(path.join(dir, 'nested.apk'), { recursive: true });
|
||||
|
||||
const names = (await readInstalledExtensions(dir)).map((e) => e.fallbackName);
|
||||
// Sorted, case-insensitive extension match, directories excluded.
|
||||
assert.deepEqual(names, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('readInstalledExtensions returns empty for a missing directory', async () => {
|
||||
assert.deepEqual(await readInstalledExtensions('/nonexistent/subminer/extensions'), []);
|
||||
});
|
||||
|
||||
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
||||
const extension: InstalledExtension = {
|
||||
file: '/x/a.apk',
|
||||
fallbackName: 'a',
|
||||
sha256: 'hash-a',
|
||||
versionCode: 1,
|
||||
};
|
||||
assert.equal(toBridgeSource(extension).sourceId, undefined);
|
||||
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
|
||||
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
|
||||
});
|
||||
|
||||
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a', versionCode: 1 },
|
||||
];
|
||||
const client = fakeClient(async () => [
|
||||
{ id: 101, name: 'Source One', lang: 'en' },
|
||||
{ id: '102', name: 'Source Two', lang: 'ja' },
|
||||
]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
|
||||
assert.equal(sources.length, 2);
|
||||
// Numeric bridge ids are normalized and package-qualified for UI state.
|
||||
assert.equal(sources[0]?.id, 'multi:101');
|
||||
assert.equal(sources[0]?.bridgeId, '101');
|
||||
assert.equal(sources[0]?.name, 'Source One');
|
||||
assert.equal(sources[1]?.lang, 'ja');
|
||||
});
|
||||
|
||||
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one', versionCode: 1 },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two', versionCode: 2 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
|
||||
assert.deepEqual(
|
||||
sources.map(({ id, bridgeId, pkg }) => ({ id, bridgeId, pkg })),
|
||||
[
|
||||
{ id: 'pkg.one:shared', bridgeId: 'shared', pkg: 'pkg.one' },
|
||||
{ id: 'pkg.two:shared', bridgeId: 'shared', pkg: 'pkg.two' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('listExtensionSources falls back to the file name and a default language', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a', versionCode: 1 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
assert.equal(sources[0]?.name, 'my-ext');
|
||||
assert.equal(sources[0]?.lang, 'all');
|
||||
});
|
||||
|
||||
test('listExtensionSources drops descriptors with no usable id', async () => {
|
||||
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
||||
const sources = await listExtensionSources(client, [
|
||||
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a', versionCode: 1 },
|
||||
]);
|
||||
assert.deepEqual(sources, []);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a', versionCode: 7 },
|
||||
];
|
||||
const sources: ExtensionSource[] = [
|
||||
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||
{ id: 'multi:2', bridgeId: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
|
||||
];
|
||||
|
||||
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
|
||||
{
|
||||
pkg: 'multi',
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
sources: [
|
||||
{ id: 'multi:1', name: 'One' },
|
||||
{ id: 'multi:2', name: 'Two' },
|
||||
],
|
||||
versionCode: 7,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a', versionCode: null },
|
||||
];
|
||||
|
||||
// A broken APK is still installed, so it must stay listed and removable.
|
||||
assert.deepEqual(
|
||||
toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
|
||||
[
|
||||
{
|
||||
pkg: 'broken',
|
||||
name: 'broken',
|
||||
langs: [],
|
||||
sourceCount: 0,
|
||||
sources: [],
|
||||
versionCode: null,
|
||||
error: 'dex2jar failed',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('one broken extension does not hide the working ones', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a', versionCode: null },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b', versionCode: 1 },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
const client = fakeClient(async (source) => {
|
||||
if (source.fingerprint === 'hash-a') throw new Error('dex2jar failed');
|
||||
return [{ id: '7', name: 'Good Source', lang: 'en' }];
|
||||
});
|
||||
|
||||
const sources = await listExtensionSources(client, extensions, (extension) => {
|
||||
failures.push(extension.fallbackName);
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['broken']);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.name, 'Good Source');
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import path from 'node:path';
|
||||
import { readApkVersionCode } from './apk-version';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BridgeSource } from './bridge-client';
|
||||
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Anime extensions are Aniyomi APKs the user supplies. They are read from a
|
||||
* directory rather than fetched from a hardcoded catalogue, so which sources
|
||||
* exist is entirely the user's choice.
|
||||
*/
|
||||
|
||||
export interface InstalledExtension {
|
||||
/** Absolute path to the .apk. */
|
||||
file: string;
|
||||
/** File name without extension, used when the bridge reports no name. */
|
||||
fallbackName: string;
|
||||
/**
|
||||
* SHA-256 of the APK. Identifies the build rather than the slot, so the
|
||||
* bridge's extension-id cache misses after an in-place upgrade.
|
||||
*/
|
||||
sha256: string;
|
||||
/** Android manifest version code, or null when the APK cannot declare one. */
|
||||
versionCode: number | null;
|
||||
}
|
||||
|
||||
export interface ExtensionSource {
|
||||
/** Package-qualified id used by the UI and runtime. */
|
||||
id: string;
|
||||
/** Raw bridge id, which selects this source inside a factory APK. */
|
||||
bridgeId: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
pkg: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover every .apk in `directory`. A missing directory yields no extensions.
|
||||
*
|
||||
* Only a hash is kept, never the bytes: APKs run to several MB each and a
|
||||
* base64 copy adds a third on top, so holding the whole set for the lifetime of
|
||||
* the Anime Browser would cost far more than re-reading a file on the rare
|
||||
* upload. Hashing streams, so peak memory stays flat regardless of APK size.
|
||||
*/
|
||||
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extensions: InstalledExtension[] = [];
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
|
||||
const file = path.join(directory, entry.name);
|
||||
const [sha256, versionCode] = await Promise.all([hashFile(file), readApkVersionCode(file)]);
|
||||
extensions.push({
|
||||
file,
|
||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||
sha256,
|
||||
versionCode,
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
async function hashFile(file: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(createReadStream(file), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe what is on disk, for the installed list in the Extensions tab.
|
||||
*
|
||||
* Built from the directory rather than from a repository catalogue: an APK
|
||||
* dropped in by hand, or one whose repository the user has since removed, is
|
||||
* still installed and must stay removable.
|
||||
*/
|
||||
export function toInstalledExtensionViews(
|
||||
extensions: InstalledExtension[],
|
||||
sources: ExtensionSource[],
|
||||
loadFailures: ExtensionLoadFailure[],
|
||||
): InstalledExtensionView[] {
|
||||
return extensions.map((extension) => {
|
||||
const provided = sources.filter((source) => source.file === extension.file);
|
||||
const names = [...new Set(provided.map((source) => source.name))];
|
||||
return {
|
||||
pkg: extension.fallbackName,
|
||||
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
||||
langs: [...new Set(provided.map((source) => source.lang))],
|
||||
sourceCount: provided.length,
|
||||
sources: provided.map((source) => ({ id: source.id, name: source.name })),
|
||||
versionCode: extension.versionCode,
|
||||
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge payload for a specific source inside an extension.
|
||||
*
|
||||
* The APK is read on demand: after the first upload the bridge answers by
|
||||
* extension id, so most calls never touch the file at all.
|
||||
*/
|
||||
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
|
||||
return {
|
||||
fingerprint: extension.sha256,
|
||||
loadApkBase64: async () => (await readFile(extension.file)).toString('base64'),
|
||||
...(sourceId ? { sourceId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the bridge which sources each extension provides.
|
||||
*
|
||||
* An extension that fails to load is skipped rather than aborting the scan, so
|
||||
* one broken APK cannot hide every working one. Failures are reported through
|
||||
* `onError` for surfacing in the UI.
|
||||
*/
|
||||
export async function listExtensionSources(
|
||||
client: AnimeBridgeClient,
|
||||
extensions: InstalledExtension[],
|
||||
onError?: (extension: InstalledExtension, error: unknown) => void,
|
||||
): Promise<ExtensionSource[]> {
|
||||
const sources: ExtensionSource[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
|
||||
for (const descriptor of descriptors) {
|
||||
const bridgeId = descriptor.id === undefined ? null : String(descriptor.id);
|
||||
if (bridgeId === null || bridgeId.length === 0) continue;
|
||||
sources.push({
|
||||
id: `${extension.fallbackName}:${bridgeId}`,
|
||||
bridgeId,
|
||||
name: descriptor.name?.trim() || extension.fallbackName,
|
||||
lang: descriptor.lang ?? 'all',
|
||||
pkg: extension.fallbackName,
|
||||
file: extension.file,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(extension, error);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOkHttpHeaders, resolveStream, toMpvHeaderFields } from './headers';
|
||||
|
||||
test('parseOkHttpHeaders flattens the alternating name/value array', () => {
|
||||
const parsed = parseOkHttpHeaders({
|
||||
namesAndValues$okhttp: ['Referer', 'https://origin.example/', 'User-Agent', 'Aniyomi'],
|
||||
});
|
||||
assert.deepEqual(parsed, {
|
||||
Referer: 'https://origin.example/',
|
||||
'User-Agent': 'Aniyomi',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseOkHttpHeaders tolerates missing, empty, and odd-length input', () => {
|
||||
assert.deepEqual(parseOkHttpHeaders(undefined), {});
|
||||
assert.deepEqual(parseOkHttpHeaders({}), {});
|
||||
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: [] }), {});
|
||||
// A trailing name with no value is dropped rather than mapped to undefined.
|
||||
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: ['Referer'] }), {});
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields joins entries and escapes commas in values', () => {
|
||||
const fields = toMpvHeaderFields({
|
||||
Referer: 'https://origin.example/',
|
||||
Cookie: 'a=1, b=2',
|
||||
});
|
||||
assert.equal(fields, 'Referer: https://origin.example/,Cookie: a=1\\, b=2');
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields escapes backslashes so a trailing one cannot eat the separator', () => {
|
||||
const fields = toMpvHeaderFields({ Referer: 'https://origin.example/path\\', Cookie: 'a=1' });
|
||||
// Without doubling, the value's trailing backslash would escape the comma
|
||||
// and merge Cookie into the Referer entry.
|
||||
assert.equal(fields, 'Referer: https://origin.example/path\\\\,Cookie: a=1');
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields returns an empty string when there are no headers', () => {
|
||||
assert.equal(toMpvHeaderFields({}), '');
|
||||
});
|
||||
|
||||
test('resolveStream normalizes a bridge video into a playable stream', () => {
|
||||
const stream = resolveStream({
|
||||
url: 'https://origin.example/embed/1',
|
||||
quality: '1080p',
|
||||
videoUrl: 'http://127.0.0.1:8080/video/master-token',
|
||||
headers: { namesAndValues$okhttp: ['Referer', 'https://origin.example/'] },
|
||||
subtitleTracks: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
|
||||
audioTracks: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(stream, {
|
||||
url: 'http://127.0.0.1:8080/video/master-token',
|
||||
quality: '1080p',
|
||||
headers: { Referer: 'https://origin.example/' },
|
||||
subtitles: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
|
||||
audios: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveStream returns null when the extension resolved no media url', () => {
|
||||
assert.equal(resolveStream({ url: 'https://origin.example/embed/1', quality: '1080p' }), null);
|
||||
assert.equal(resolveStream({ videoUrl: '' }), null);
|
||||
});
|
||||
|
||||
test('resolveStream drops tracks without a url and defaults a missing lang', () => {
|
||||
const stream = resolveStream({
|
||||
videoUrl: 'http://127.0.0.1:8080/video/master-token',
|
||||
subtitleTracks: [{ lang: 'English' }, { url: 'http://127.0.0.1:8080/video/sub-token' }],
|
||||
});
|
||||
assert.deepEqual(stream?.subtitles, [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: '' }]);
|
||||
assert.equal(stream?.quality, '');
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { BridgeVideo, OkHttpHeaders, ResolvedStream } from './types';
|
||||
|
||||
/**
|
||||
* Flatten OkHttp's alternating `[name, value, name, value]` array into a map.
|
||||
* A trailing name with no value is dropped rather than mapped to undefined.
|
||||
*/
|
||||
export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record<string, string> {
|
||||
const flat = headers?.['namesAndValues$okhttp'];
|
||||
if (!Array.isArray(flat)) return {};
|
||||
|
||||
const parsed: Record<string, string> = {};
|
||||
for (let i = 0; i + 1 < flat.length; i += 2) {
|
||||
const name = flat[i];
|
||||
const value = flat[i + 1];
|
||||
if (typeof name === 'string' && typeof value === 'string') parsed[name] = value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render headers as mpv's `--http-header-fields` string list. mpv splits
|
||||
* entries on commas, so commas inside a value must be escaped — and the
|
||||
* backslash that does the escaping has to be escaped first, or a value ending
|
||||
* in `\` would neutralise the separator and swallow the next header.
|
||||
*/
|
||||
export function toMpvHeaderFields(headers: Record<string, string>): string {
|
||||
return Object.entries(headers)
|
||||
.map(([name, value]) => `${name}: ${value.replace(/\\/g, '\\\\').replace(/,/g, '\\,')}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function normalizeTracks(
|
||||
tracks: Array<{ url?: string; lang?: string }> | undefined,
|
||||
): Array<{ url: string; lang: string }> {
|
||||
if (!Array.isArray(tracks)) return [];
|
||||
return tracks
|
||||
.filter((track): track is { url: string; lang?: string } => typeof track.url === 'string')
|
||||
.map((track) => ({ url: track.url, lang: track.lang ?? '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a bridge video into a playable stream. Returns null when the
|
||||
* extension produced no `videoUrl`, which happens for entries it failed to
|
||||
* resolve.
|
||||
*/
|
||||
export function resolveStream(video: BridgeVideo): ResolvedStream | null {
|
||||
if (typeof video.videoUrl !== 'string' || video.videoUrl.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: video.videoUrl,
|
||||
quality: video.quality ?? '',
|
||||
headers: parseOkHttpHeaders(video.headers),
|
||||
subtitles: normalizeTracks(video.subtitleTracks),
|
||||
audios: normalizeTracks(video.audioTracks),
|
||||
};
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl, routeHlsThroughProxy } from './media-url';
|
||||
|
||||
const BRIDGE = 'http://127.0.0.1:56037';
|
||||
const PROXY = 'http://127.0.0.1:60001';
|
||||
|
||||
test('a bridge m3u8 stream is routed through the strip proxy', () => {
|
||||
assert.equal(
|
||||
routeHlsThroughProxy(`${BRIDGE}/video/master.m3u8?q=1080`, BRIDGE, PROXY),
|
||||
`${PROXY}/video/master.m3u8?q=1080`,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-HLS bridge streams stay on the bridge', () => {
|
||||
const direct = `${BRIDGE}/video/movie-token`;
|
||||
assert.equal(routeHlsThroughProxy(direct, BRIDGE, PROXY), direct);
|
||||
});
|
||||
|
||||
test('external m3u8 streams are not routed through the proxy', () => {
|
||||
const remote = 'https://cdn.example.com/hls/master.m3u8';
|
||||
assert.equal(routeHlsThroughProxy(remote, BRIDGE, PROXY), remote);
|
||||
});
|
||||
|
||||
test('unparseable stream urls pass through routeHlsThroughProxy unchanged', () => {
|
||||
assert.equal(routeHlsThroughProxy('not a url', BRIDGE, PROXY), 'not a url');
|
||||
assert.equal(
|
||||
routeHlsThroughProxy(`${BRIDGE}/video/a.m3u8`, 'garbage', PROXY),
|
||||
`${BRIDGE}/video/a.m3u8`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a loopback proxy url is rebased onto the live bridge port', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/image/cover-uuid'),
|
||||
'http://127.0.0.1:56037/image/cover-uuid',
|
||||
);
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://localhost:8080/video/master-token'),
|
||||
'http://127.0.0.1:56037/video/master-token',
|
||||
);
|
||||
});
|
||||
|
||||
test('query strings and fragments survive rebasing', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/video/token?quality=1080#t=30'),
|
||||
'http://127.0.0.1:56037/video/token?quality=1080#t=30',
|
||||
);
|
||||
});
|
||||
|
||||
test('ipv6 loopback is recognised', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://[::1]:8080/image/cover'),
|
||||
'http://127.0.0.1:56037/image/cover',
|
||||
);
|
||||
});
|
||||
|
||||
test('remote urls are left untouched', () => {
|
||||
const remote = 'https://cdn.example.com/covers/1.jpg';
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, remote), remote);
|
||||
});
|
||||
|
||||
test('loopback urls outside the proxy routes are left untouched', () => {
|
||||
// Only /image and /video are proxy routes; /capabilities is the server's own API.
|
||||
const other = 'http://127.0.0.1:8080/capabilities';
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, other), other);
|
||||
});
|
||||
|
||||
test('a base url without a scheme is assumed to be http', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl('127.0.0.1:56037', 'http://127.0.0.1:8080/image/cover'),
|
||||
'http://127.0.0.1:56037/image/cover',
|
||||
);
|
||||
});
|
||||
|
||||
test('unparseable input is returned unchanged rather than throwing', () => {
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, 'not a url'), 'not a url');
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl('', 'http://127.0.0.1:8080/image/c'),
|
||||
'http://127.0.0.1:8080/image/c',
|
||||
);
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, ''), '');
|
||||
});
|
||||
|
||||
test('parseAnimeStatus maps the SAnime constants', () => {
|
||||
assert.equal(parseAnimeStatus(1), 'ongoing');
|
||||
assert.equal(parseAnimeStatus(2), 'completed');
|
||||
assert.equal(parseAnimeStatus(4), 'publishing-finished');
|
||||
assert.equal(parseAnimeStatus(5), 'cancelled');
|
||||
assert.equal(parseAnimeStatus(6), 'on-hiatus');
|
||||
assert.equal(parseAnimeStatus(0), 'unknown');
|
||||
assert.equal(parseAnimeStatus(undefined), 'unknown');
|
||||
// 3 is unused in the SAnime constants.
|
||||
assert.equal(parseAnimeStatus(3), 'unknown');
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* The bridge returns cover art and video URLs pointing at its own loopback
|
||||
* media proxy, but the origin it embeds is not always the port we actually
|
||||
* started it on. Rebase those onto the live bridge origin, and leave any
|
||||
* genuinely remote URL untouched.
|
||||
*/
|
||||
|
||||
const PROXY_ROUTES = new Set(['image', 'video']);
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
||||
|
||||
function isLoopbackProxyUrl(candidate: URL): boolean {
|
||||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') return false;
|
||||
const host = candidate.hostname.toLowerCase();
|
||||
if (!LOOPBACK_HOSTS.has(host)) return false;
|
||||
const route = candidate.pathname.split('/').filter(Boolean)[0];
|
||||
return route !== undefined && PROXY_ROUTES.has(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a bridge media URL onto `bridgeBaseUrl`, preserving path and query.
|
||||
* Returns the input unchanged when it is not a loopback proxy URL, or when
|
||||
* either URL cannot be parsed.
|
||||
*/
|
||||
export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): string {
|
||||
let media: URL;
|
||||
try {
|
||||
media = new URL(mediaUrl);
|
||||
} catch {
|
||||
return mediaUrl;
|
||||
}
|
||||
if (!isLoopbackProxyUrl(media)) return mediaUrl;
|
||||
|
||||
const normalizedBase = bridgeBaseUrl.includes('://') ? bridgeBaseUrl : `http://${bridgeBaseUrl}`;
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(normalizedBase);
|
||||
} catch {
|
||||
return mediaUrl;
|
||||
}
|
||||
if (base.protocol !== 'http:' && base.protocol !== 'https:') return mediaUrl;
|
||||
if (base.hostname.length === 0) return mediaUrl;
|
||||
|
||||
const rebased = new URL(base.origin);
|
||||
rebased.pathname = media.pathname;
|
||||
rebased.search = media.search;
|
||||
rebased.hash = media.hash;
|
||||
return rebased.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a bridge-served HLS stream through the local strip proxy instead. Only
|
||||
* `.m3u8` URLs on the bridge origin qualify: direct files need no fixing, and
|
||||
* an external URL would not resolve through a proxy that forwards to the
|
||||
* bridge. Anything unparseable comes back unchanged.
|
||||
*/
|
||||
export function routeHlsThroughProxy(
|
||||
streamUrl: string,
|
||||
bridgeBaseUrl: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
let stream: URL;
|
||||
let bridge: URL;
|
||||
try {
|
||||
stream = new URL(streamUrl);
|
||||
bridge = new URL(bridgeBaseUrl);
|
||||
} catch {
|
||||
return streamUrl;
|
||||
}
|
||||
if (stream.origin !== bridge.origin) return streamUrl;
|
||||
if (!stream.pathname.endsWith('.m3u8')) return streamUrl;
|
||||
return `${proxyOrigin}${stream.pathname}${stream.search}`;
|
||||
}
|
||||
|
||||
/** Aniyomi's SAnime status constants. */
|
||||
export type AnimeStatus =
|
||||
| 'unknown'
|
||||
| 'ongoing'
|
||||
| 'completed'
|
||||
| 'publishing-finished'
|
||||
| 'cancelled'
|
||||
| 'on-hiatus';
|
||||
|
||||
export function parseAnimeStatus(status: number | undefined): AnimeStatus {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'ongoing';
|
||||
case 2:
|
||||
return 'completed';
|
||||
case 4:
|
||||
return 'publishing-finished';
|
||||
case 5:
|
||||
return 'cancelled';
|
||||
case 6:
|
||||
return 'on-hiatus';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildLoadfileOptions,
|
||||
buildPlaybackCommands,
|
||||
buildQueuedLoadfileOptions,
|
||||
buildQueuedPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
normalizeLangTag,
|
||||
selectPreferredStream,
|
||||
} from './mpv-playback';
|
||||
import type { ResolvedStream } from './types';
|
||||
|
||||
function stream(overrides: Partial<ResolvedStream> = {}): ResolvedStream {
|
||||
return {
|
||||
url: 'http://127.0.0.1:8080/video/token',
|
||||
quality: '1080p',
|
||||
headers: {},
|
||||
subtitles: [],
|
||||
audios: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('loadfile options keep one visible track and never scan the filesystem', () => {
|
||||
const options = buildLoadfileOptions({ stream: stream() });
|
||||
for (const expected of [
|
||||
'sub-auto=no',
|
||||
'secondary-sid=no',
|
||||
'secondary-sub-visibility=no',
|
||||
'sub-visibility=yes',
|
||||
]) {
|
||||
assert.ok(options.split(',').includes(expected), `missing ${expected}`);
|
||||
}
|
||||
// The source's own subtitles are the only ones this path gets, so they must
|
||||
// not be suppressed the way the Jellyfin path suppresses them.
|
||||
assert.ok(!options.split(',').includes('sid=no'));
|
||||
// Comma-separated values would split the option list, so the language
|
||||
// preferences ride as properties instead.
|
||||
assert.ok(!options.includes('alang'));
|
||||
assert.ok(!options.includes('slang'));
|
||||
});
|
||||
|
||||
test('queued playback carries file-local title and language preferences into mpv', () => {
|
||||
const options = {
|
||||
stream: {
|
||||
url: 'https://video.example/episode.m3u8',
|
||||
quality: '1080p',
|
||||
headers: { Referer: 'https://source.example/watch?a=1,b=2' },
|
||||
audios: [],
|
||||
subtitles: [],
|
||||
},
|
||||
title: 'Series, Part 1 = Episode 2',
|
||||
};
|
||||
|
||||
const languagePreference = 'ja,jpn,jp,japanese';
|
||||
assert.ok(
|
||||
buildQueuedLoadfileOptions(options).includes(
|
||||
`alang=%${Buffer.byteLength(languagePreference)}%${languagePreference}`,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
buildQueuedLoadfileOptions(options).includes(
|
||||
`force-media-title=%${Buffer.byteLength(options.title)}%${options.title}`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(buildQueuedPlaybackCommands(options), [
|
||||
[
|
||||
'loadfile',
|
||||
'https://video.example/episode.m3u8',
|
||||
'append-play',
|
||||
-1,
|
||||
buildQueuedLoadfileOptions(options),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('headers are percent-escaped so their commas do not split the option list', () => {
|
||||
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
|
||||
const options = buildLoadfileOptions({ stream: stream({ headers }) });
|
||||
|
||||
// Verified against mpv 0.41: the unescaped form yields an empty header list.
|
||||
const fields = 'Referer: https://a.test/,User-Agent: X';
|
||||
assert.ok(options.includes(`http-header-fields=%${fields.length}%${fields}`));
|
||||
});
|
||||
|
||||
test('the escape length counts the full header string including separators', () => {
|
||||
const options = buildLoadfileOptions({
|
||||
stream: stream({ headers: { Cookie: 'a=1, b=2' } }),
|
||||
});
|
||||
// The comma inside the value is backslash-escaped first, so the length grows.
|
||||
const fields = 'Cookie: a=1\\, b=2';
|
||||
assert.ok(options.includes(`%${fields.length}%${fields}`));
|
||||
});
|
||||
|
||||
test('the escape length is counted in utf-8 bytes, not js string units', () => {
|
||||
// An extension may put a non-ASCII value in a header; mpv reads %n% as a
|
||||
// byte count, so counting string units would truncate the value.
|
||||
const options = buildLoadfileOptions({
|
||||
stream: stream({ headers: { 'X-Title': '日本語' } }),
|
||||
});
|
||||
const fields = 'X-Title: 日本語';
|
||||
assert.ok(options.includes(`%${Buffer.byteLength(fields, 'utf8')}%${fields}`));
|
||||
assert.ok(!options.includes(`%${fields.length}%`));
|
||||
});
|
||||
|
||||
test('no header option is emitted when the stream carries no headers', () => {
|
||||
const options = buildLoadfileOptions({ stream: stream() });
|
||||
assert.ok(!options.includes('http-header-fields'));
|
||||
});
|
||||
|
||||
test('a positive start position is appended, zero is omitted', () => {
|
||||
assert.ok(buildLoadfileOptions({ stream: stream(), startSeconds: 42 }).includes('start=42'));
|
||||
assert.ok(!buildLoadfileOptions({ stream: stream(), startSeconds: 0 }).includes('start='));
|
||||
assert.ok(!buildLoadfileOptions({ stream: stream() }).includes('start='));
|
||||
});
|
||||
|
||||
test('playback commands set the language preference before loading the file', () => {
|
||||
const commands = buildPlaybackCommands({ stream: stream(), title: 'Example - 01' });
|
||||
|
||||
assert.deepEqual(commands[0], ['script-message', 'subminer-managed-subtitles-loading']);
|
||||
// Japanese first, so a multi-audio stream never starts on the dub. slang is
|
||||
// Japanese-only: an English track belongs in the secondary slot, which the
|
||||
// secondarySub auto-load fills by language tag.
|
||||
assert.deepEqual(commands[1], ['set_property', 'alang', 'ja,jpn,jp,japanese']);
|
||||
assert.deepEqual(commands[2], ['set_property', 'slang', 'ja,jpn,jp,japanese']);
|
||||
assert.equal(commands[3]?.[0], 'loadfile');
|
||||
assert.equal(commands[3]?.[1], 'http://127.0.0.1:8080/video/token');
|
||||
assert.equal(commands[3]?.[2], 'replace');
|
||||
assert.equal(commands[3]?.[3], -1);
|
||||
assert.deepEqual(commands[4], ['set_property', 'force-media-title', 'Example - 01']);
|
||||
});
|
||||
|
||||
test('force-media-title is skipped when there is no title', () => {
|
||||
assert.equal(buildPlaybackCommands({ stream: stream() }).length, 4);
|
||||
assert.equal(buildPlaybackCommands({ stream: stream(), title: '' }).length, 4);
|
||||
});
|
||||
|
||||
test('external audio tracks are added, with the Japanese one selected', () => {
|
||||
const commands = buildTrackCommands(
|
||||
stream({
|
||||
audios: [
|
||||
{ url: 'http://host/en.m4a', lang: 'en' },
|
||||
{ url: 'http://host/ja.m4a', lang: 'ja' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['audio-add', 'http://host/en.m4a', 'auto', 'en', 'en'],
|
||||
['audio-add', 'http://host/ja.m4a', 'select', 'ja', 'ja'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('external audio is left unselected when none of it is Japanese', () => {
|
||||
// alang already picked a track off the container; do not override it.
|
||||
const commands = buildTrackCommands(
|
||||
stream({ audios: [{ url: 'http://host/en.m4a', lang: 'eng' }] }),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [['audio-add', 'http://host/en.m4a', 'auto', 'eng', 'en']]);
|
||||
});
|
||||
|
||||
test('only a Japanese subtitle track is selected as primary', () => {
|
||||
const japanese = buildTrackCommands(
|
||||
stream({
|
||||
subtitles: [
|
||||
{ url: 'http://host/en.vtt', lang: 'English' },
|
||||
{ url: 'http://host/ja.vtt', lang: 'Japanese' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(japanese[1], ['sub-add', 'http://host/ja.vtt', 'select', 'Japanese', 'ja']);
|
||||
assert.equal(japanese[0]?.[2], 'auto');
|
||||
|
||||
// English is the user's *secondary* language; it must not take the primary
|
||||
// slot. It rides in unselected, tagged so the secondarySub auto-load can
|
||||
// route it to secondary-sid.
|
||||
const englishOnly = buildTrackCommands(
|
||||
stream({ subtitles: [{ url: 'http://host/en.vtt', lang: 'English' }] }),
|
||||
);
|
||||
assert.deepEqual(englishOnly, [['sub-add', 'http://host/en.vtt', 'auto', 'English', 'en']]);
|
||||
});
|
||||
|
||||
test('language labels normalize to the tags users configure', () => {
|
||||
assert.equal(normalizeLangTag('English'), 'en');
|
||||
assert.equal(normalizeLangTag('eng'), 'en');
|
||||
assert.equal(normalizeLangTag('en-US'), 'en');
|
||||
assert.equal(normalizeLangTag('Japanese'), 'ja');
|
||||
assert.equal(normalizeLangTag('jpn'), 'ja');
|
||||
assert.equal(normalizeLangTag('Português'), 'pt');
|
||||
// Unknown labels pass through untouched rather than being guessed at.
|
||||
assert.equal(normalizeLangTag('Klingon'), 'Klingon');
|
||||
assert.equal(normalizeLangTag(''), '');
|
||||
});
|
||||
|
||||
test('unlabelled tracks still get a usable menu title, duplicates are dropped', () => {
|
||||
const commands = buildTrackCommands(
|
||||
stream({
|
||||
subtitles: [
|
||||
{ url: 'http://host/a.vtt', lang: '' },
|
||||
{ url: 'http://host/a.vtt', lang: '' },
|
||||
{ url: 'http://host/b.vtt', lang: '' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', 'http://host/a.vtt', 'auto', 'Subtitle 1', ''],
|
||||
['sub-add', 'http://host/b.vtt', 'auto', 'Subtitle 2', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stream with no external tracks emits no track commands', () => {
|
||||
assert.deepEqual(buildTrackCommands(stream()), []);
|
||||
});
|
||||
|
||||
test('selectPreferredStream skips dub entries in favour of the original audio', () => {
|
||||
const streams = [
|
||||
stream({ quality: '1080p (Dub)' }),
|
||||
stream({ quality: '720p (Sub)' }),
|
||||
stream({ quality: '480p (Dub)' }),
|
||||
];
|
||||
|
||||
// Language beats the quality hint: a 1080p dub is the wrong file, not a
|
||||
// better one.
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '720p (Sub)');
|
||||
assert.equal(selectPreferredStream(streams, '1080')?.quality, '720p (Sub)');
|
||||
});
|
||||
|
||||
test('selectPreferredStream prefers an entry carrying a Japanese audio track', () => {
|
||||
const streams = [
|
||||
stream({ quality: '1080p' }),
|
||||
stream({ quality: '720p', audios: [{ url: 'http://host/ja.m4a', lang: 'ja' }] }),
|
||||
];
|
||||
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '720p');
|
||||
});
|
||||
|
||||
test('an all-dub list still plays rather than failing', () => {
|
||||
const streams = [stream({ quality: '1080p Dub' }), stream({ quality: '720p Dub' })];
|
||||
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '1080p Dub');
|
||||
assert.equal(selectPreferredStream(streams, '720')?.quality, '720p Dub');
|
||||
});
|
||||
|
||||
test('selectPreferredStream honours a quality hint, else takes the first', () => {
|
||||
const streams = [stream({ quality: '360p' }), stream({ quality: '1080p' })];
|
||||
|
||||
assert.equal(selectPreferredStream(streams, '1080')?.quality, '1080p');
|
||||
assert.equal(selectPreferredStream(streams, '1080P')?.quality, '1080p');
|
||||
// Extensions label streams with the host name, so the hint matches a substring.
|
||||
const decorated = [
|
||||
stream({ quality: 'Doodstream - 360p' }),
|
||||
stream({ quality: 'Vidhide - 720p' }),
|
||||
];
|
||||
assert.equal(selectPreferredStream(decorated, '720')?.quality, 'Vidhide - 720p');
|
||||
// Extensions pre-sort by their own preference, so the first entry wins.
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '360p');
|
||||
// A hint that matches nothing falls back rather than failing.
|
||||
assert.equal(selectPreferredStream(streams, '4k')?.quality, '360p');
|
||||
});
|
||||
|
||||
test('selectPreferredStream returns null for an empty list', () => {
|
||||
assert.equal(selectPreferredStream([]), null);
|
||||
assert.equal(selectPreferredStream([], '1080p'), null);
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
import { toMpvHeaderFields } from './headers';
|
||||
import type { ResolvedStream } from './types';
|
||||
|
||||
/**
|
||||
* Japanese first, always, and for subtitles Japanese *only*: the primary slot
|
||||
* belongs to the language being mined, and an English track belongs in the
|
||||
* secondary slot, where the `secondarySub` machinery puts it by language tag.
|
||||
* For audio, mpv falls back to the first track when nothing matches, so an
|
||||
* English-only release still plays.
|
||||
*/
|
||||
export const JAPANESE_LANGUAGE_PREFERENCE = 'ja,jpn,jp,japanese';
|
||||
|
||||
/**
|
||||
* mpv must not scan the filesystem for sidecar subtitles when the "file" is a
|
||||
* network stream, and the secondary slot stays empty so the overlay only ever
|
||||
* reads one track. Everything else is left to normal track selection, driven
|
||||
* by the language preferences above.
|
||||
*
|
||||
* `alang`/`slang` are set as properties instead of file-local options: their
|
||||
* values are comma-separated lists, and a comma inside a `loadfile` option
|
||||
* value splits the option list.
|
||||
*/
|
||||
const BASE_LOADFILE_OPTIONS = [
|
||||
'sub-auto=no',
|
||||
'secondary-sid=no',
|
||||
'secondary-sub-visibility=no',
|
||||
'sub-visibility=yes',
|
||||
];
|
||||
|
||||
/** Matches a language tag or a label such as "Japanese (Sub)" or "[JPN]". */
|
||||
const JAPANESE_PATTERN = /(^|[^a-z])(ja|jp|jpn|japanese|日本語)([^a-z]|$)/i;
|
||||
/** Extensions label dub entries in the quality string, e.g. "1080p (Dub)". */
|
||||
const DUB_PATTERN = /(^|[^a-z])(dub|dubbed|dublado|latino|castellano)([^a-z]|$)/i;
|
||||
/** The counterpart label for original-audio entries, e.g. "SUB - 1080p". */
|
||||
const SUBBED_PATTERN = /(^|[^a-z])(sub|subbed|softsub|hardsub|subtitulado|raw)([^a-z]|$)/i;
|
||||
|
||||
export type MpvCommand = Array<string | number>;
|
||||
|
||||
export interface BuildPlaybackOptions {
|
||||
stream: ResolvedStream;
|
||||
/** Shown as the mpv window/OSD title. */
|
||||
title?: string;
|
||||
/** Resume position in seconds. */
|
||||
startSeconds?: number;
|
||||
}
|
||||
|
||||
export function isJapaneseTag(value: string): boolean {
|
||||
return JAPANESE_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mpv `loadfile` option string for a stream.
|
||||
*
|
||||
* Headers ride as `file-local-options/http-header-fields` so they apply to this
|
||||
* file only, and so SubMiner's Anki media path can read them back off mpv when
|
||||
* generating card audio and screenshots. Tracks added later with `sub-add` /
|
||||
* `audio-add` inherit them too, which is how external tracks on an
|
||||
* authenticated host stay reachable.
|
||||
*/
|
||||
export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
|
||||
const parts = [...BASE_LOADFILE_OPTIONS];
|
||||
|
||||
const headerFields = toMpvHeaderFields(options.stream.headers);
|
||||
if (headerFields.length > 0) {
|
||||
// Escape the mpv option-list separators so a header never splits the list.
|
||||
parts.push(`http-header-fields=${escapeOptionValue(headerFields)}`);
|
||||
}
|
||||
|
||||
if (options.startSeconds !== undefined && options.startSeconds > 0) {
|
||||
parts.push(`start=${options.startSeconds}`);
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the file-local options needed when a resolved stream waits in mpv's
|
||||
* playlist. Unlike the regular playback path, there is no opportunity to set
|
||||
* global properties immediately before mpv advances to this file.
|
||||
*/
|
||||
export function buildQueuedLoadfileOptions(options: BuildPlaybackOptions): string {
|
||||
const parts = [
|
||||
buildLoadfileOptions(options),
|
||||
`alang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
|
||||
`slang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
|
||||
];
|
||||
if (options.title !== undefined && options.title.length > 0) {
|
||||
parts.push(`force-media-title=${escapeOptionValue(options.title)}`);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
|
||||
* containing either must be quoted. Percent-encoding is mpv's own escape for
|
||||
* embedded separators in option values.
|
||||
*
|
||||
* The count is in UTF-8 bytes of the decoded value, not JS string units, so a
|
||||
* non-ASCII header value (extensions supply these) would otherwise under-count
|
||||
* and mpv would cut the value short.
|
||||
*/
|
||||
function escapeOptionValue(value: string): string {
|
||||
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered mpv commands that start playback of a resolved stream.
|
||||
*
|
||||
* The plugin is told subtitles are being managed before the file loads, so the
|
||||
* overlay does not flash the source's own tracks during the swap.
|
||||
*/
|
||||
export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
|
||||
const commands: MpvCommand[] = [
|
||||
['script-message', 'subminer-managed-subtitles-loading'],
|
||||
['set_property', 'alang', JAPANESE_LANGUAGE_PREFERENCE],
|
||||
['set_property', 'slang', JAPANESE_LANGUAGE_PREFERENCE],
|
||||
['loadfile', options.stream.url, 'replace', -1, buildLoadfileOptions(options)],
|
||||
];
|
||||
|
||||
if (options.title !== undefined && options.title.length > 0) {
|
||||
commands.push(['set_property', 'force-media-title', options.title]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/** Append a fully resolved stream without replacing the file playing now. */
|
||||
export function buildQueuedPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
|
||||
return [['loadfile', options.stream.url, 'append-play', -1, buildQueuedLoadfileOptions(options)]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that attach the extension's external audio and subtitle tracks.
|
||||
*
|
||||
* These must be sent *after* the file is loading, so they are separate from
|
||||
* {@link buildPlaybackCommands}. Every track is added — even the ones we do not
|
||||
* select — so they show up in mpv's track menu and can be switched by hand.
|
||||
*/
|
||||
export function buildTrackCommands(stream: ResolvedStream): MpvCommand[] {
|
||||
return [
|
||||
...buildAddTrackCommands('audio-add', stream.audios, 'Audio'),
|
||||
...buildAddTrackCommands('sub-add', stream.subtitles, 'Subtitle'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a Japanese track is ever selected outright — the primary slot is for
|
||||
* the mining language. A non-Japanese track is added unselected: for audio,
|
||||
* `alang`'s pick off the container stands; for subtitles, the `secondarySub`
|
||||
* auto-load matches the track's language tag against the user's configured
|
||||
* secondary languages and routes it to `secondary-sid` instead.
|
||||
*/
|
||||
function buildAddTrackCommands(
|
||||
command: 'audio-add' | 'sub-add',
|
||||
tracks: Array<{ url: string; lang: string }>,
|
||||
kind: 'Audio' | 'Subtitle',
|
||||
): MpvCommand[] {
|
||||
const unique = dedupeByUrl(tracks);
|
||||
const selected = unique.findIndex((track) => isJapaneseTag(track.lang));
|
||||
|
||||
return unique.map((track, index) => [
|
||||
command,
|
||||
track.url,
|
||||
index === selected ? 'select' : 'auto',
|
||||
track.lang || `${kind} ${index + 1}`,
|
||||
normalizeLangTag(track.lang),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Extension language labels mapped to the tags users put in config. */
|
||||
const LANG_TAG_BY_LABEL: Record<string, string> = {
|
||||
japanese: 'ja',
|
||||
日本語: 'ja',
|
||||
english: 'en',
|
||||
eng: 'en',
|
||||
spanish: 'es',
|
||||
español: 'es',
|
||||
portuguese: 'pt',
|
||||
português: 'pt',
|
||||
french: 'fr',
|
||||
français: 'fr',
|
||||
german: 'de',
|
||||
deutsch: 'de',
|
||||
italian: 'it',
|
||||
italiano: 'it',
|
||||
indonesian: 'id',
|
||||
arabic: 'ar',
|
||||
russian: 'ru',
|
||||
korean: 'ko',
|
||||
chinese: 'zh',
|
||||
thai: 'th',
|
||||
vietnamese: 'vi',
|
||||
};
|
||||
|
||||
/**
|
||||
* mpv's `lang` field is what SubMiner's secondary-subtitle auto-load compares
|
||||
* against `secondarySub.secondarySubLanguages`, so a label like "English" must
|
||||
* become the tag a user would actually configure. Unknown labels pass through;
|
||||
* matching is best-effort, and the raw label stays visible as the track title.
|
||||
*/
|
||||
export function normalizeLangTag(lang: string): string {
|
||||
const trimmed = lang.trim();
|
||||
if (isJapaneseTag(trimmed)) return 'ja';
|
||||
const mapped = LANG_TAG_BY_LABEL[trimmed.toLowerCase()];
|
||||
if (mapped !== undefined) return mapped;
|
||||
if (/^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)?$/.test(trimmed)) {
|
||||
return trimmed.split(/[-_]/, 1)[0]?.toLowerCase() ?? trimmed.toLowerCase();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function dedupeByUrl(
|
||||
tracks: Array<{ url: string; lang: string }>,
|
||||
): Array<{ url: string; lang: string }> {
|
||||
const seen = new Set<string>();
|
||||
return tracks.filter((track) => {
|
||||
if (track.url.length === 0 || seen.has(track.url)) return false;
|
||||
seen.add(track.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank a stream by how likely it is to carry Japanese audio.
|
||||
*
|
||||
* Sources commonly return the dub and the original as separate entries rather
|
||||
* than as two audio tracks of one entry, so the choice of *entry* is the first
|
||||
* place a dub can slip in.
|
||||
*/
|
||||
function scoreStream(stream: ResolvedStream): number {
|
||||
if (stream.audios.some((audio) => isJapaneseTag(audio.lang))) return 2;
|
||||
const label = stream.quality;
|
||||
if (isJapaneseTag(label) || SUBBED_PATTERN.test(label)) return 1;
|
||||
if (DUB_PATTERN.test(label)) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best stream from an extension's video list.
|
||||
*
|
||||
* Japanese audio outranks the quality hint — a 1080p dub is the wrong file, not
|
||||
* a better one. Within the surviving entries the hint decides, and otherwise
|
||||
* the extension's own ordering does.
|
||||
*/
|
||||
export function selectPreferredStream(
|
||||
streams: ResolvedStream[],
|
||||
preferredQuality?: string,
|
||||
): ResolvedStream | null {
|
||||
if (streams.length === 0) return null;
|
||||
|
||||
const best = Math.max(...streams.map(scoreStream));
|
||||
const candidates = streams.filter((stream) => scoreStream(stream) === best);
|
||||
|
||||
if (preferredQuality !== undefined && preferredQuality.length > 0) {
|
||||
const needle = preferredQuality.toLowerCase();
|
||||
const match = candidates.find((stream) => stream.quality.toLowerCase().includes(needle));
|
||||
if (match) return match;
|
||||
}
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { interleave, mapSourcesConcurrently } from './multi-source-search';
|
||||
|
||||
const source = (id: string) => ({ id, name: `Source ${id}` });
|
||||
|
||||
test('mapSourcesConcurrently returns results in source order, not completion order', async () => {
|
||||
const sources = [source('a'), source('b'), source('c')];
|
||||
const delays: Record<string, number> = { a: 20, b: 0, c: 10 };
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delays[target.id]));
|
||||
return target.id;
|
||||
});
|
||||
|
||||
assert.deepEqual(results, ['a', 'b', 'c']);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('a failing source is reported without losing the others', async () => {
|
||||
const sources = [source('a'), source('b'), source('c')];
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
|
||||
if (target.id === 'b') throw new Error('login required');
|
||||
return target.id;
|
||||
});
|
||||
|
||||
assert.deepEqual(results, ['a', 'c']);
|
||||
assert.deepEqual(failures, [{ sourceId: 'b', sourceName: 'Source b', error: 'login required' }]);
|
||||
});
|
||||
|
||||
test('mapSourcesConcurrently never runs more than the concurrency limit at once', async () => {
|
||||
const sources = ['a', 'b', 'c', 'd', 'e'].map(source);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
|
||||
await mapSourcesConcurrently(
|
||||
sources,
|
||||
async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running -= 1;
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(peak, 2);
|
||||
});
|
||||
|
||||
test('mapSourcesConcurrently handles an empty source list', async () => {
|
||||
const { results, failures } = await mapSourcesConcurrently([], async () => 'x');
|
||||
assert.deepEqual(results, []);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('interleave takes one from each source before taking a second', () => {
|
||||
assert.deepEqual(interleave([['a1', 'a2', 'a3'], ['b1'], ['c1', 'c2']]), [
|
||||
'a1',
|
||||
'b1',
|
||||
'c1',
|
||||
'a2',
|
||||
'c2',
|
||||
'a3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleave ignores empty groups', () => {
|
||||
assert.deepEqual(interleave([[], ['b1', 'b2'], []]), ['b1', 'b2']);
|
||||
assert.deepEqual(interleave([]), []);
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { SourceSearchFailure } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Running one query against every installed source at once.
|
||||
*
|
||||
* Each source is a separate extension behind the same single-threaded bridge,
|
||||
* so the fan-out is bounded rather than unleashed: a dozen extensions all
|
||||
* uploading and searching at once starves the ones the user is waiting on.
|
||||
*/
|
||||
|
||||
/** Enough to hide the latency of a slow source without queueing the bridge. */
|
||||
const DEFAULT_CONCURRENCY = 4;
|
||||
|
||||
export interface SourceTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FanOutResult<T> {
|
||||
/** One entry per source that succeeded, in source order. */
|
||||
results: T[];
|
||||
/** One entry per source that threw, in source order. */
|
||||
failures: SourceSearchFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` against every source, at most `concurrency` at a time.
|
||||
*
|
||||
* A source that throws becomes a failure instead of rejecting the whole call —
|
||||
* one misconfigured extension must not hide every other source's results.
|
||||
*/
|
||||
export async function mapSourcesConcurrently<S extends SourceTarget, T>(
|
||||
sources: S[],
|
||||
task: (source: S) => Promise<T>,
|
||||
concurrency: number = DEFAULT_CONCURRENCY,
|
||||
): Promise<FanOutResult<T>> {
|
||||
// Slots keep the output in source order regardless of completion order, so
|
||||
// the same query lays out the same way twice.
|
||||
const results: Array<{ value: T } | null> = sources.map(() => null);
|
||||
const failures: Array<SourceSearchFailure | null> = sources.map(() => null);
|
||||
let next = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const source = sources[index];
|
||||
if (!source) return;
|
||||
try {
|
||||
results[index] = { value: await task(source) };
|
||||
} catch (error) {
|
||||
failures[index] = {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Math.max(1, Math.min(concurrency, sources.length));
|
||||
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||
|
||||
return {
|
||||
results: results
|
||||
.filter((slot): slot is { value: T } => slot !== null)
|
||||
.map((slot) => slot.value),
|
||||
failures: failures.filter((slot): slot is SourceSearchFailure => slot !== null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-robin merge, so the grid opens with one hit from each source rather
|
||||
* than the whole of the first source before the second one starts.
|
||||
*/
|
||||
export function interleave<T>(groups: T[][]): T[] {
|
||||
const merged: T[] = [];
|
||||
const longest = groups.reduce((max, group) => Math.max(max, group.length), 0);
|
||||
for (let index = 0; index < longest; index += 1) {
|
||||
for (const group of groups) {
|
||||
if (index < group.length) merged.push(group[index] as T);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { watchPlaybackOutcome, type PlaybackEndFileEvent } from './playback-outcome';
|
||||
|
||||
type Harness = {
|
||||
emitEndFile: (event: PlaybackEndFileEvent) => void;
|
||||
listenerCount: () => number;
|
||||
setProperty: (name: string, value: unknown) => void;
|
||||
failProperty: (name: string) => void;
|
||||
/** Virtual milliseconds burned so far, by sleeps and by property reads. */
|
||||
elapsed: () => number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The clock is virtual and only moves when the code under test sleeps (or,
|
||||
* with `readCostMs`, when it reads a property), so timeout behaviour is
|
||||
* asserted without any real waiting.
|
||||
*/
|
||||
function createHarness(overrides?: {
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
readCostMs?: number;
|
||||
}) {
|
||||
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
|
||||
const properties = new Map<string, unknown>();
|
||||
const failing = new Set<string>();
|
||||
const readCostMs = overrides?.readCostMs ?? 0;
|
||||
let clock = 0;
|
||||
|
||||
const watch = watchPlaybackOutcome({
|
||||
onEndFile: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
readProperty: async (name) => {
|
||||
clock += readCostMs;
|
||||
if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`);
|
||||
return properties.get(name);
|
||||
},
|
||||
wait: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
now: () => clock,
|
||||
timeoutMs: overrides?.timeoutMs ?? 1000,
|
||||
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
|
||||
});
|
||||
|
||||
const harness: Harness = {
|
||||
emitEndFile: (event) => {
|
||||
for (const listener of listeners) listener(event);
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
setProperty: (name, value) => properties.set(name, value),
|
||||
failProperty: (name) => failing.add(name),
|
||||
elapsed: () => clock,
|
||||
};
|
||||
return { watch, harness };
|
||||
}
|
||||
|
||||
test('resolves ok once mpv configures a video output', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
harness.setProperty('vo-configured', true);
|
||||
const outcome = await watch.wait();
|
||||
assert.deepEqual(outcome, { ok: true });
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('resolves failure with the mpv error when the file ends in error', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
const pending = watch.wait();
|
||||
harness.emitEndFile({ reason: 'error', fileError: 'no audio or video data played' });
|
||||
const outcome = await pending;
|
||||
assert.equal(outcome.ok, false);
|
||||
assert.ok(!outcome.ok && outcome.error.includes('no audio or video data played'));
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('ignores the end-file fired for the file being replaced', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
const pending = watch.wait();
|
||||
harness.emitEndFile({ reason: 'stop', fileError: null });
|
||||
harness.setProperty('vo-configured', true);
|
||||
const outcome = await pending;
|
||||
assert.deepEqual(outcome, { ok: true });
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('times out with a failure when nothing ever starts', async () => {
|
||||
const { watch, harness } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 });
|
||||
const outcome = await watch.wait();
|
||||
assert.equal(outcome.ok, false);
|
||||
assert.ok(!outcome.ok && outcome.error.length > 0);
|
||||
assert.equal(harness.elapsed(), 300);
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('slow property reads eat the budget instead of extending it', async () => {
|
||||
const { watch, harness } = createHarness({
|
||||
timeoutMs: 300,
|
||||
probeIntervalMs: 100,
|
||||
readCostMs: 250,
|
||||
});
|
||||
const outcome = await watch.wait();
|
||||
assert.equal(outcome.ok, false);
|
||||
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
|
||||
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
|
||||
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('a zero probe interval still terminates at the deadline', async () => {
|
||||
const { watch } = createHarness({ timeoutMs: 200, probeIntervalMs: 0, readCostMs: 50 });
|
||||
const outcome = await watch.wait();
|
||||
assert.equal(outcome.ok, false);
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('keeps polling through property read failures', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
harness.failProperty('vo-configured');
|
||||
const pending = watch.wait();
|
||||
harness.emitEndFile({ reason: 'error', fileError: null });
|
||||
const outcome = await pending;
|
||||
assert.equal(outcome.ok, false);
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('dispose removes the end-file subscription', () => {
|
||||
const { watch, harness } = createHarness();
|
||||
assert.equal(harness.listenerCount(), 1);
|
||||
watch.dispose();
|
||||
assert.equal(harness.listenerCount(), 0);
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Confirms that a `loadfile` handed to mpv actually turned into playback.
|
||||
*
|
||||
* Sending the command proves nothing: mpv accepts the file, fails to decode it
|
||||
* (a dead host, a disguised stream the proxy could not fix), fires `end-file`
|
||||
* with reason "error", and drops back to `--idle` — with no window, because
|
||||
* idle mpv shows none. The UI would happily say "Playing" over a blank desktop.
|
||||
*
|
||||
* Success is mpv configuring a video output (`vo-configured`), which is
|
||||
* literally "a window with frames in it". `file-loaded` is not enough — the
|
||||
* broken stream in the wild reached it before dying. Failure is an `end-file`
|
||||
* with reason "error"; the end-file of the file being *replaced* arrives with
|
||||
* "stop"/"redirect" and is ignored.
|
||||
*/
|
||||
|
||||
export interface PlaybackEndFileEvent {
|
||||
reason: string;
|
||||
fileError: string | null;
|
||||
}
|
||||
|
||||
export type PlaybackOutcome = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface WatchPlaybackOutcomeDeps {
|
||||
/** Subscribe to mpv end-file events; returns the unsubscribe. */
|
||||
onEndFile: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
|
||||
/** One-shot mpv property read; may reject while the file is still loading. */
|
||||
readProperty: (name: string) => Promise<unknown>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
/** Injectable clock; the timeout is wall-clock, not a probe count. */
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface PlaybackOutcomeWatch {
|
||||
wait: () => Promise<PlaybackOutcome>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_PROBE_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Call *before* sending `loadfile` so the error subscription cannot lose a
|
||||
* race against a fast failure; await `wait()` after the commands went out.
|
||||
*/
|
||||
export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOutcomeWatch {
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS;
|
||||
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
||||
|
||||
let failure: PlaybackOutcome | null = null;
|
||||
const unsubscribe = deps.onEndFile((event) => {
|
||||
if (event.reason !== 'error') return;
|
||||
failure = {
|
||||
ok: false,
|
||||
error: event.fileError
|
||||
? `mpv could not play this stream: ${event.fileError}`
|
||||
: 'mpv could not play this stream.',
|
||||
};
|
||||
});
|
||||
|
||||
async function wait(): Promise<PlaybackOutcome> {
|
||||
// Wall-clock, not a probe count: a slow `readProperty` must eat into the
|
||||
// budget rather than stretch it, and a zero probe interval must still end.
|
||||
const now = deps.now ?? Date.now;
|
||||
const deadline = now() + timeoutMs;
|
||||
while (now() < deadline) {
|
||||
if (failure) return failure;
|
||||
try {
|
||||
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
|
||||
} catch {
|
||||
// The property is unreadable while mpv is between files; keep polling.
|
||||
}
|
||||
if (failure) return failure;
|
||||
// Sleeping past the deadline would only delay the timeout report.
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) break;
|
||||
await deps.wait(Math.min(probeIntervalMs, remaining));
|
||||
}
|
||||
return (
|
||||
failure ?? {
|
||||
ok: false,
|
||||
error: 'Playback did not start. mpv gave no error; try another server or quality.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { wait, dispose: unsubscribe };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PreferenceStore } from './preference-store';
|
||||
|
||||
async function storeFile(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-prefs-'));
|
||||
return path.join(dir, 'anime-preferences.json');
|
||||
}
|
||||
|
||||
test('values round-trip through the file', async () => {
|
||||
const file = await storeFile();
|
||||
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'address' }]);
|
||||
|
||||
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), [{ key: 'address' }]);
|
||||
});
|
||||
|
||||
test('the same bridge source id is isolated between extension packages', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
|
||||
await store.set('pkg.one', 'shared-source', [{ key: 'password', value: 'one-secret' }]);
|
||||
await store.set('pkg.two', 'shared-source', [{ key: 'password', value: 'two-secret' }]);
|
||||
|
||||
const reloaded = new PreferenceStore(file);
|
||||
assert.deepEqual(await reloaded.get('pkg.one', 'shared-source'), [
|
||||
{ key: 'password', value: 'one-secret' },
|
||||
]);
|
||||
assert.deepEqual(await reloaded.get('pkg.two', 'shared-source'), [
|
||||
{ key: 'password', value: 'two-secret' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a legacy bare source id is discarded rather than assigned to an unproven package', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(file, JSON.stringify({ 'legacy-source': [{ key: 'address', value: 'saved' }] }));
|
||||
|
||||
const store = new PreferenceStore(file);
|
||||
assert.deepEqual(await store.get('pkg.one', 'legacy-source'), []);
|
||||
|
||||
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(persisted['legacy-source'], undefined);
|
||||
assert.equal(persisted['pkg.one:legacy-source'], undefined);
|
||||
});
|
||||
|
||||
test('an ambiguous legacy source id is discarded instead of exposed to either package', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(file, JSON.stringify({ shared: [{ key: 'password', value: 'old-secret' }] }));
|
||||
|
||||
const store = new PreferenceStore(file);
|
||||
assert.deepEqual(await store.get('pkg.one', 'shared'), []);
|
||||
assert.deepEqual(await store.get('pkg.two', 'shared'), []);
|
||||
|
||||
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(persisted.shared, undefined);
|
||||
});
|
||||
|
||||
test('concurrent writes on a cold cache do not lose an update', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
|
||||
// Both start before either has loaded; unserialized they would each get their
|
||||
// own object and the later persist would drop the other's entry.
|
||||
await Promise.all([
|
||||
store.set('pkg', 'src-1', [{ key: 'a' }]),
|
||||
store.set('pkg', 'src-2', [{ key: 'b' }]),
|
||||
]);
|
||||
|
||||
const reloaded = new PreferenceStore(file);
|
||||
assert.deepEqual(await reloaded.get('pkg', 'src-1'), [{ key: 'a' }]);
|
||||
assert.deepEqual(await reloaded.get('pkg', 'src-2'), [{ key: 'b' }]);
|
||||
});
|
||||
|
||||
test('a clear racing a set is applied in order', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
await store.set('pkg', 'src', [{ key: 'password' }]);
|
||||
|
||||
await Promise.all([store.clear('pkg'), store.set('other', 'src', [{ key: 'x' }])]);
|
||||
|
||||
const reloaded = new PreferenceStore(file);
|
||||
assert.deepEqual(await reloaded.get('pkg', 'src'), []);
|
||||
assert.deepEqual(await reloaded.get('other', 'src'), [{ key: 'x' }]);
|
||||
});
|
||||
|
||||
test('the file is written owner-only even when an existing temporary file is permissive', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(`${file}.tmp`, 'stale');
|
||||
await chmod(`${file}.tmp`, 0o666);
|
||||
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'password' }]);
|
||||
|
||||
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
||||
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
|
||||
});
|
||||
|
||||
test('a corrupt file starts empty rather than blocking the browser', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(file, '{ not json');
|
||||
|
||||
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), []);
|
||||
});
|
||||
|
||||
test('malformed persisted values are filtered to preference objects with string keys', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
'pkg:source': [null, { key: 42 }, 'bad', { key: 'valid', value: 'kept' }],
|
||||
'pkg:not-an-array': { key: 'invalid-container' },
|
||||
}),
|
||||
);
|
||||
|
||||
const store = new PreferenceStore(file);
|
||||
assert.deepEqual(await store.get('pkg', 'source'), [{ key: 'valid', value: 'kept' }]);
|
||||
assert.deepEqual(await store.get('pkg', 'not-an-array'), []);
|
||||
});
|
||||
|
||||
test('a write replaces the previous contents wholesale', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
await store.set('pkg', 'src-1', [{ key: 'first' }]);
|
||||
await store.set('pkg', 'src-1', [{ key: 'second' }]);
|
||||
|
||||
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
|
||||
assert.deepEqual(parsed['pkg:src-1'], [{ key: 'second' }]);
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { chmod, readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
function parseStoredPreferences(value: unknown): Record<string, BridgePreference[]> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
|
||||
const parsed: Record<string, BridgePreference[]> = {};
|
||||
for (const [key, entries] of Object.entries(value)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
parsed[key] = entries.filter(
|
||||
(entry): entry is BridgePreference =>
|
||||
entry !== null &&
|
||||
typeof entry === 'object' &&
|
||||
!Array.isArray(entry) &&
|
||||
typeof (entry as Record<string, unknown>).key === 'string',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists each source's preference array verbatim, keyed by extension package
|
||||
* and bridge source id.
|
||||
*
|
||||
* Extensions keep credentials in here (the Jellyfin source stores a password),
|
||||
* so the file is written with owner-only permissions.
|
||||
*/
|
||||
export class PreferenceStore {
|
||||
private readonly file: string;
|
||||
private cache: Record<string, BridgePreference[]> | null = null;
|
||||
/**
|
||||
* Mutations run one at a time. Two concurrent load-modify-persist cycles
|
||||
* starting on a cold cache would each read their own object, and the later
|
||||
* write would drop the earlier one's edit.
|
||||
*/
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(file: string) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(operation, operation);
|
||||
// Keep the chain alive after a rejection so one failure cannot wedge it.
|
||||
this.queue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async load(): Promise<Record<string, BridgePreference[]>> {
|
||||
if (this.cache !== null) return this.cache;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
|
||||
this.cache = parseStoredPreferences(parsed);
|
||||
} catch {
|
||||
// Missing or corrupt file starts empty rather than blocking the browser.
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async get(pkg: string, sourceId: string): Promise<BridgePreference[]> {
|
||||
return this.enqueue(async () => {
|
||||
const all = await this.load();
|
||||
const key = `${pkg}:${sourceId}`;
|
||||
if (all[key]) return all[key];
|
||||
|
||||
// Bare source IDs predate package scoping and have no trustworthy owner.
|
||||
// Never assign their credentials to whichever package happens to ask first.
|
||||
const legacy = all[sourceId];
|
||||
if (legacy) {
|
||||
delete all[sourceId];
|
||||
await this.persist(all);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise<void> {
|
||||
await this.enqueue(async () => {
|
||||
const all = await this.load();
|
||||
all[`${pkg}:${sourceId}`] = preferences;
|
||||
await this.persist(all);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every saved value whose key starts with `prefix`.
|
||||
*
|
||||
* Removing an extension should not leave its credentials on disk, and a
|
||||
* source id is not knowable once the APK is gone — so callers pass the
|
||||
* package name and this clears anything recorded under it.
|
||||
*/
|
||||
async clear(prefix: string): Promise<void> {
|
||||
await this.enqueue(async () => {
|
||||
const all = await this.load();
|
||||
let changed = false;
|
||||
for (const key of Object.keys(all)) {
|
||||
if (key === prefix || key.startsWith(`${prefix}:`)) {
|
||||
delete all[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await this.persist(all);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write through a temporary file and rename into place.
|
||||
*
|
||||
* A write interrupted partway would otherwise leave truncated JSON, and
|
||||
* `load()` treats unparseable content as empty — which would quietly discard
|
||||
* every saved credential.
|
||||
*/
|
||||
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
|
||||
await mkdir(path.dirname(this.file), { recursive: true });
|
||||
const temporary = `${this.file}.tmp`;
|
||||
try {
|
||||
await chmod(temporary, 0o600).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
|
||||
await chmod(temporary, 0o600);
|
||||
await rename(temporary, this.file);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyPreferenceValue,
|
||||
isSecretPreference,
|
||||
parsePreferences,
|
||||
type SourcePreferenceView,
|
||||
} from './preferences';
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
// Shapes taken from the real Jellyfin extension's preferencesAnime response.
|
||||
const RAW: BridgePreference[] = [
|
||||
{
|
||||
key: 'host_url',
|
||||
editTextPreference: {
|
||||
title: 'Address',
|
||||
summary: 'The server address',
|
||||
value: '',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
editTextPreference: { title: 'Password', summary: 'The user account password', value: '' },
|
||||
},
|
||||
{
|
||||
key: 'pref_quality',
|
||||
listPreference: {
|
||||
title: 'Preferred quality',
|
||||
summary: 'Preferred quality.',
|
||||
valueIndex: 0,
|
||||
entries: ['Source', '20 Mbps'],
|
||||
entryValues: ['source', '20000000'],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pref_episode_details_key',
|
||||
multiSelectListPreference: {
|
||||
title: 'Additional details for episodes',
|
||||
values: [],
|
||||
entries: ['Overview', 'Runtime'],
|
||||
entryValues: ['overview', 'runtime'],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pref_trust_cert',
|
||||
switchPreferenceCompat: { title: 'Trust certificate', value: false },
|
||||
},
|
||||
];
|
||||
|
||||
function view(views: SourcePreferenceView[], key: string): SourcePreferenceView {
|
||||
const found = views.find((candidate) => candidate.key === key);
|
||||
assert.ok(found, `missing preference ${key}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
test('parsePreferences flattens each widget type', () => {
|
||||
const views = parsePreferences(RAW);
|
||||
assert.equal(views.length, 5);
|
||||
|
||||
assert.equal(view(views, 'host_url').kind, 'text');
|
||||
assert.equal(view(views, 'host_url').title, 'Address');
|
||||
assert.equal(view(views, 'host_url').value, '');
|
||||
|
||||
const quality = view(views, 'pref_quality');
|
||||
assert.equal(quality.kind, 'list');
|
||||
// valueIndex 0 resolves through entryValues, not entries.
|
||||
assert.equal(quality.value, 'source');
|
||||
assert.deepEqual(quality.entries, ['Source', '20 Mbps']);
|
||||
|
||||
assert.deepEqual(view(views, 'pref_episode_details_key').value, []);
|
||||
assert.equal(view(views, 'pref_trust_cert').value, false);
|
||||
});
|
||||
|
||||
test('parsePreferences skips the bridge context entry and unknown widgets', () => {
|
||||
const views = parsePreferences([
|
||||
{ key: '__mangatan_bridge_context__', sourceId: '1' },
|
||||
{ key: 'mystery', someFutureWidget: { title: 'X' } },
|
||||
...RAW.slice(0, 1),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
views.map((v) => v.key),
|
||||
['host_url'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a list preference with no selection reads as empty', () => {
|
||||
const views = parsePreferences([
|
||||
{
|
||||
key: 'library_pref',
|
||||
listPreference: {
|
||||
title: 'Select media library',
|
||||
valueIndex: -1,
|
||||
entries: [],
|
||||
entryValues: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(view(views, 'library_pref').value, '');
|
||||
});
|
||||
|
||||
test('applyPreferenceValue writes text into both value and text', () => {
|
||||
const updated = applyPreferenceValue(RAW, 'host_url', 'https://media.example');
|
||||
const body = updated.find((e) => e.key === 'host_url')!.editTextPreference as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.value, 'https://media.example');
|
||||
assert.equal(body.text, 'https://media.example');
|
||||
// Other entries are untouched.
|
||||
assert.equal(parsePreferences(updated).length, RAW.length);
|
||||
});
|
||||
|
||||
test('applyPreferenceValue moves a list preference by entry value', () => {
|
||||
const updated = applyPreferenceValue(RAW, 'pref_quality', '20000000');
|
||||
const body = updated.find((e) => e.key === 'pref_quality')!.listPreference as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.valueIndex, 1);
|
||||
assert.equal(parsePreferences(updated).find((v) => v.key === 'pref_quality')?.value, '20000000');
|
||||
});
|
||||
|
||||
test('applyPreferenceValue handles multi-select and switch widgets', () => {
|
||||
const multi = applyPreferenceValue(RAW, 'pref_episode_details_key', ['overview']);
|
||||
assert.deepEqual(
|
||||
parsePreferences(multi).find((v) => v.key === 'pref_episode_details_key')?.value,
|
||||
['overview'],
|
||||
);
|
||||
|
||||
const toggled = applyPreferenceValue(RAW, 'pref_trust_cert', true);
|
||||
assert.equal(parsePreferences(toggled).find((v) => v.key === 'pref_trust_cert')?.value, true);
|
||||
});
|
||||
|
||||
test('applyPreferenceValue leaves unknown keys alone', () => {
|
||||
assert.deepEqual(applyPreferenceValue(RAW, 'not-a-key', 'x'), RAW);
|
||||
});
|
||||
|
||||
test('secrets are recognised by key or title', () => {
|
||||
const views = parsePreferences(RAW);
|
||||
assert.equal(isSecretPreference(view(views, 'password')), true);
|
||||
assert.equal(isSecretPreference(view(views, 'host_url')), false);
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
/**
|
||||
* Extension preferences arrive as Android preference objects, one wrapper key
|
||||
* per widget type. They are stored and sent back verbatim so the extension sees
|
||||
* exactly the shape it produced; only the value field is edited.
|
||||
*/
|
||||
|
||||
export type PreferenceKind = 'text' | 'list' | 'multi' | 'switch';
|
||||
|
||||
/** A preference flattened for rendering. */
|
||||
export interface SourcePreferenceView {
|
||||
key: string;
|
||||
kind: PreferenceKind;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
/** Current value: string for text/list, string[] for multi, boolean for switch. */
|
||||
value: string | string[] | boolean;
|
||||
/** Display labels, parallel to entryValues. Empty for text/switch. */
|
||||
entries: string[];
|
||||
entryValues: string[];
|
||||
}
|
||||
|
||||
const WIDGETS = {
|
||||
editTextPreference: 'text',
|
||||
listPreference: 'list',
|
||||
multiSelectListPreference: 'multi',
|
||||
switchPreferenceCompat: 'switch',
|
||||
checkBoxPreference: 'switch',
|
||||
} as const satisfies Record<string, PreferenceKind>;
|
||||
|
||||
type WidgetName = keyof typeof WIDGETS;
|
||||
|
||||
function widgetOf(
|
||||
entry: BridgePreference,
|
||||
): { name: WidgetName; body: Record<string, unknown> } | null {
|
||||
for (const name of Object.keys(WIDGETS) as WidgetName[]) {
|
||||
const body = entry[name];
|
||||
if (body !== null && typeof body === 'object') {
|
||||
return { name, body: body as Record<string, unknown> };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Flatten bridge preference entries for display. Unknown widgets are skipped. */
|
||||
export function parsePreferences(raw: BridgePreference[]): SourcePreferenceView[] {
|
||||
const views: SourcePreferenceView[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
if (typeof entry.key !== 'string' || entry.key.startsWith('__')) continue;
|
||||
const widget = widgetOf(entry);
|
||||
if (!widget) continue;
|
||||
|
||||
const { body } = widget;
|
||||
const kind = WIDGETS[widget.name];
|
||||
const entries = stringList(body.entries);
|
||||
const entryValues = stringList(body.entryValues);
|
||||
|
||||
let value: string | string[] | boolean;
|
||||
if (kind === 'multi') {
|
||||
value = stringList(body.values);
|
||||
} else if (kind === 'switch') {
|
||||
value = body.value === true;
|
||||
} else if (kind === 'list') {
|
||||
const index = typeof body.valueIndex === 'number' ? body.valueIndex : -1;
|
||||
// valueIndex is -1 when the extension has no selection yet.
|
||||
value = index >= 0 && index < entryValues.length ? entryValues[index]! : '';
|
||||
} else {
|
||||
value = typeof body.value === 'string' ? body.value : '';
|
||||
}
|
||||
|
||||
views.push({
|
||||
key: entry.key,
|
||||
kind,
|
||||
title: typeof body.title === 'string' ? body.title : entry.key,
|
||||
summary: typeof body.summary === 'string' ? body.summary : null,
|
||||
value,
|
||||
entries,
|
||||
entryValues,
|
||||
});
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `raw` with one preference's value replaced, in whichever
|
||||
* fields that widget type reads. Unknown keys are returned unchanged.
|
||||
*/
|
||||
export function applyPreferenceValue(
|
||||
raw: BridgePreference[],
|
||||
key: string,
|
||||
value: string | string[] | boolean,
|
||||
): BridgePreference[] {
|
||||
return raw.map((entry) => {
|
||||
if (entry.key !== key) return entry;
|
||||
const widget = widgetOf(entry);
|
||||
if (!widget) return entry;
|
||||
|
||||
const body = { ...widget.body };
|
||||
const kind = WIDGETS[widget.name];
|
||||
|
||||
if (kind === 'multi') {
|
||||
body.values = Array.isArray(value) ? value : [];
|
||||
} else if (kind === 'switch') {
|
||||
body.value = value === true;
|
||||
} else if (kind === 'list') {
|
||||
const entryValues = stringList(body.entryValues);
|
||||
const index = entryValues.indexOf(String(value));
|
||||
body.valueIndex = index;
|
||||
if (index >= 0) body.value = entryValues[index];
|
||||
} else {
|
||||
// editTextPreference carries the same string in both value and text.
|
||||
body.value = String(value);
|
||||
body.text = String(value);
|
||||
}
|
||||
|
||||
return { ...entry, [widget.name]: body };
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the preference should be masked in the UI and in logs. */
|
||||
export function isSecretPreference(view: SourcePreferenceView): boolean {
|
||||
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user