Compare commits

...
Author SHA1 Message Date
sudacode 4209134399 feat(subtitles): optimize anime stream audio extraction
- Prefer matching audio and lower-resolution sources with HLS sampling
- Reuse verified audio within sessions and clean temporary files on shutdown
- Allow character dictionary sync without loaded subtitles
2026-09-22 00:20:54 -07:00
sudacode 2c89bc1754 feat(subtitles): generate subtitles from anime streams
- Support finite HTTP/HTTPS streams with cached generated SRT files
- Document stream requirements and limitations
2026-09-21 21:28:15 -07:00
sudacode 625f1ad238 feat(stats): add live-action library support and harden stats runtime
- Add TMDB metadata, linking, filtering, and live-action library merging
- Harden stats request validation, lifecycle handling, and compiled runtime coverage
- Fix Anki media synchronization and configuration validation
2026-09-21 00:18:32 -07:00
sudacode 1508863dbb feat(stats): add TMDB metadata for live-action dramas in the Library (#252) 2026-09-21 00:10:00 -07:00
sudacode 4dd30f44d1 chore(yomitan): update fork to upstream 26.9.8 2026-09-21 00:09:48 -07:00
sudacode ab48a5678e fix(stats): restrict local requests and serve the dashboard over HTTP (#263) 2026-09-20 23:36:55 -07:00
sudacode 2f21582666 fix(overlay): cancel pending window transitions and timing reviews (#262) 2026-09-20 23:35:48 -07:00
sudacode 383aed8bad fix(stats): harden server lifecycle and verify compiled runtime (#261) 2026-09-20 23:19:03 -07:00
sudacode f9c4e892dc fix(stats): reject malformed resource IDs before mutations (#259) 2026-09-20 22:49:27 -07:00
sudacode 7959e1a4e5 fix(anki): honor unlimited duration in stats mining (#258) 2026-09-20 22:49:12 -07:00
sudacode 917d52dd94 fix(config): validate AnkiConnect and field grouping settings (#257) 2026-09-20 22:48:54 -07:00
sudacode 05425cc5db fix(anki): separate word audio mapping for animation sync (#256) 2026-09-20 22:07:30 -07:00
sudacode d1aa279830 feat: expand subtitle and media tracking workflows
- Add reference-guided subtitle timing, frame picking, live-action Jimaku search, and YouTube library kinds
- Harden Jellyfin media identity handling and live settings feedback
- Refresh user-facing documentation and changelog fragments
2026-09-20 20:10:03 -07:00
sudacode 8097ed27e5 feat(stats): separate YouTube channels in the Library (#253) 2026-09-20 19:57:32 -07:00
sudacode 76e43d7e6d fix(config): align live save feedback with hot reload policy (#255) 2026-09-20 19:51:45 -07:00
Abdulrazzaq Alhendi 4f0762e840 feat(mining): add a screenshot frame picker to media review (#254) 2026-09-20 19:39:30 -07:00
sudacode 026d495fac feat(jimaku): add live-action subtitle search (#251) 2026-09-18 22:50:13 -07:00
sudacode dd76782d30 fix(subtitles): widen subtitle generation modal
- Reduce text wrapping and scrolling while retaining small-window support
- Document the responsive layout change
2026-09-18 21:05:52 -07:00
sudacode a8c16147ad feat(subtitles): use loaded subtitles to guide generation timing (#249) 2026-09-18 00:24:42 -07:00
sudacode 186d4a0640 fix(jellyfin): fix jellyfin media metadata (#250) 2026-09-18 00:24:25 -07:00
284 changed files with 13620 additions and 1584 deletions

No files matched your search

+8
View File
@@ -13,6 +13,9 @@ on:
required: true
APPLE_TEAM_ID:
required: true
# Project TMDB key baked into release artifacts; builds stay valid without it.
SUBMINER_TMDB_API_KEY:
required: false
permissions:
contents: read
@@ -69,6 +72,8 @@ jobs:
- name: Build AppImage
run: bun run build:appimage
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
- name: Build unversioned AppImage
run: |
@@ -168,6 +173,7 @@ jobs:
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
@@ -248,6 +254,8 @@ jobs:
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
- name: Smoke packaged runtime assets
shell: bash
+1
View File
@@ -27,6 +27,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
release:
needs: [package]
+10 -13
View File
@@ -93,12 +93,20 @@ jobs:
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
- name: Launcher unit and script suites
run: bun run test:launcher:unit:src && bun run test:scripts
- name: Environment suite
run: bun run test:env
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
@@ -112,17 +120,6 @@ jobs:
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
+1
View File
@@ -28,6 +28,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
release:
needs: [package]
+12 -3
View File
@@ -95,8 +95,8 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
<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>
<td><b>Japanese Subtitle Generation</b></td>
<td>Generate Japanese subtitles from local files or finite anime streams in a standalone modal (<code>Ctrl+Shift+G</code>) or the sidebar button. The launcher supports local files. Transcription runs locally with whisper.cpp and FFmpeg, with progress and optional managed model downloads. Stream subtitles are saved in the application cache. 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>
@@ -277,7 +277,7 @@ SubMiner builds on the work of these open-source projects:
| [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 |
| [Bun](https://github.com/oven-sh/bun) | Bundled runtime for the `subminer` command-line launcher |
| [Bun](https://github.com/oven-sh/bun) | Bundled runtime for the `subminer` command-line launcher |
| [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 |
@@ -287,6 +287,15 @@ SubMiner builds on the work of these open-source projects:
| [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 |
## Disclaimer
SubMiner is a media player and language-learning tool. The project does not
host, supply, or link to anime, video streams, or subtitle libraries. The anime
browser runs Aniyomi extensions that you install yourself from repositories you
choose; SubMiner bundles none and recommends none. The SubMiner developers are
not affiliated with any content provider and have no control over what those
sources offer, whether they stay available, or the quality of what they serve.
## License
[GNU General Public License v3.0](LICENSE)
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: anime
- Added a README disclaimer stating that SubMiner hosts no content, ships no anime sources or repositories, is not affiliated with any content provider, and that users are responsible for the content they access. The anime browser docs no longer describe SubMiner as a "host" of extensions.
+1 -1
View File
@@ -19,7 +19,7 @@ area: anime
- 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.
- "Playing" is only reported once mpv actually configures a video output; slow streams remain loading while mpv opens them, and failed streams show mpv's error.
- 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.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: anime
- Document how the anime browser waits for slow streams to start without reporting a premature playback failure.
@@ -0,0 +1,5 @@
type: docs
area: subtitles
- Document anime stream generation, audio-only and lower-resolution extraction candidates, concurrent HLS downloads, temporary disk usage, audio cache reuse and limits, full-episode processing, supported audio tracks, and stream access limitations.
- Explain HLS source-check diagnostics and session-only audio cleanup on quit and after crashes.
@@ -0,0 +1,6 @@
type: added
area: subtitles
- Generate Japanese subtitles from finite HTTP/HTTPS anime episode streams in the existing generation modal. Reuse mpv's supported request headers and the anime proxy, transcribe downloaded audio locally, retain generated SRT files in the application cache, and load them only into the matching stream. Local-file generation and the launcher keep their existing behavior.
- Prepare plain HLS episodes with up to four concurrent segment downloads, preserving audio order and timing. Show download progress, cancel active requests together, and retain FFmpeg extraction for complex playlists.
- Reduce anime-stream extraction downloads by preferring separate Japanese audio or lower-resolution video when duration and sampled audio match playback. Sample HLS through short local segment windows, account for timestamp origins, retry empty original samples, and report why candidates are rejected. Selected external HTTP audio tracks are extracted directly with their playback delay preserved. Reuse verified completed audio across transcription retries and model changes within the current app session. Remove downloaded audio and working files on quit, recover abandoned sessions on startup, and preserve saved subtitles.
@@ -0,0 +1,4 @@
type: docs
area: subtitles
- Clarify that character dictionary sync works without a subtitle track and explain readiness and import timeouts.
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Let character dictionaries finish syncing when no Japanese subtitles are loaded. Wait for Yomitan extension readiness instead of the first processed subtitle, and report readiness failures instead of leaving the notification stuck on Checking.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: ci
- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: verification
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: jellyfin
- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats.
- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived.
- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments.
- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty.
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Stats server startup reports port conflicts without crashing SubMiner, shares concurrent startup requests, and shows in-app startup errors through configured status notifications.
- Background stop cancels pending background startup without disconnecting foreground-only dashboards. Shutdown bounds the wait for active HTTP requests and awaits tracker finalization before exit, with a deadline for forced application exit.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration.
@@ -2,3 +2,4 @@ type: docs
area: subtitles
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process.
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
+2
View File
@@ -4,3 +4,5 @@ area: subtitles
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections. Use a wider generation modal to reduce text wrapping and scrolling, while keeping it within smaller player windows.
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
+5
View File
@@ -0,0 +1,5 @@
type: docs
area: jellyfin
- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata.
- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: jimaku
- Jimaku modal gains Anime / Live action tabs so searches can pull from Jimaku's live action catalogue instead of only anime entries (Arrow Left / Right switch tabs).
@@ -0,0 +1,4 @@
type: docs
area: mining
- Document independent screenshot selection in media timing review.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: mining
- Choose a still screenshot independently of the audio in media timing review, with a live preview, a separate time slider, and decoded-frame stepping. Local video and seekable remote streams such as Jellyfin are supported.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
@@ -0,0 +1,4 @@
type: docs
area: config
- Clarified live-setting save feedback, mixed restart warnings, and subtitle-generation reload behavior.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: config
- Settings marked LIVE now use the same reload policy as save results, fixing false restart warnings for notifications and subtitle generation.
- Mixed saves apply live changes and list only sections with changed fields that require a restart.
+6
View File
@@ -0,0 +1,6 @@
type: changed
breaking: true
area: stats
- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies.
- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown.
+8
View File
@@ -0,0 +1,8 @@
type: added
area: stats
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library kind selector gained a Live Action option alongside Anime and YouTube.
- Provider reassignment preserves the previous link and artwork if the replacement download fails, and refreshes completion totals when the episode count changes. Merges and sync keep conflicting AniList and TMDB identities separate, and the merge dialog explains when a selection mixes the two instead of failing.
- TMDB credential commands cache successful output and wait 30 seconds before retrying failed or empty output, using the bundled key in the meantime when available.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: anki
- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: yomitan
- Updated bundled Yomitan with upstream 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Document YouTube channel filtering and video statistics in the Library.
+7
View File
@@ -0,0 +1,7 @@
type: added
area: stats
- Store YouTube channels as a separate media kind and migrate existing channel entries without changing viewing history or manual video assignments, including channels created after temporarily returning to an older build.
- Add All Titles, Anime, and YouTube Library filters, identify channel pages, and keep channels out of AniList matching, season repair, and duplicate recommendations.
- Keep same-title anime and YouTube records separate in storage and sync while repairing legacy channel classification.
- Refuse merges and video moves that would mix an anime entry with a YouTube channel; the move picker and merge selection only combine entries of the same kind.
+13 -2
View File
@@ -547,7 +547,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// 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.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/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.
// ==========================================
@@ -568,6 +568,7 @@
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
@@ -596,7 +597,7 @@
"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.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -679,6 +680,16 @@
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// TMDB
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// Hot-reload: TMDB changes apply to the next TMDB request.
// ==========================================
"tmdb": {
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
+4
View File
@@ -47,6 +47,8 @@ The update flow:
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
Updates are skipped if the media path cannot produce a safe, nonempty identity. Invalid entries are discarded when loading or adding to the retry queue.
| Parameter | Value |
| ---------------- | ---------- |
| Initial backoff | 30 seconds |
@@ -69,6 +71,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped.
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
+13 -3
View File
@@ -43,14 +43,16 @@ extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner
```
Because the extension resolves the stream, whichever sources you install decide
what is available. SubMiner only hosts them.
what is available. SubMiner runs the installed extensions locally and nothing
more.
## 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.
browser has nothing to search — that is deliberate. Every repository and
extension comes from you, and SubMiner is not affiliated with any of the sites
they connect to.
There are two ways to add extensions.
@@ -339,6 +341,10 @@ 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).
Slow streams stay in the loading state while mpv is still opening them, even
after the initial playback confirmation deadline. Loading ends when video
appears, mpv reports an error or returns to idle, or another episode replaces
the request.
Failed requests appear in a dismissible panel at the bottom of the browser.
It explains the failed action and suggests a next step. Expand **Technical
@@ -390,6 +396,10 @@ 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.
### Generating Japanese subtitles
While an episode is playing, press **Ctrl+Shift+G** to [generate Japanese subtitles](/subtitle-generation#generating-for-anime-streams) from its audio. SubMiner fetches the complete episode audio, transcribes it locally with whisper.cpp, and loads the resulting SRT if the same stream is still playing. Generated subtitles stay in the application cache. This requires a finite HTTP/HTTPS stream with an internal audio track; live streams and separately attached audio tracks are not supported.
### Series, season, and episode
The episode's identity travels with it instead of being guessed back out of the
+14 -1
View File
@@ -19,6 +19,8 @@ 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.
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
## Auto-enrichment transport
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:
@@ -125,6 +127,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
"fields": {
"word": "Expression", // mined word / expression text
"audio": "SentenceAudio", // sentence audio clip cut from the video
"wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync
"image": "Picture", // screenshot or animated clip
"sentence": "Sentence", // subtitle text
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
@@ -136,6 +139,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
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.
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
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).
@@ -183,12 +188,18 @@ 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]`.
Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap.
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.
**Choosing the screenshot.** With still images enabled, drag the screenshot slider or use the Frame buttons to choose a video frame independently of the audio. Earlier and Later reveal more time for both sliders. The image follows the audio midpoint until you pick a frame, then stays fixed while you trim audio. Reset below the image restores automatic selection; the audio Reset affects only the audio. The screenshot slider also supports arrow keys, Home, and End.
The picker supports local video and seekable remote streams, including Jellyfin, without seeking the main player. A selected frame must load before you can confirm; if it fails, choose another frame or Reset. The picker is hidden when image generation is disabled or animated AVIF is selected. Animated images continue to use the reviewed audio range.
**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.
@@ -203,6 +214,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
### Screenshots (static)
A single frame is captured at the current playback position.
@@ -237,7 +250,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of
}
```
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.
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 audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images.
### Behavior options
+3 -1
View File
@@ -35,6 +35,8 @@ Character dictionary sync is disabled by default. To turn it on:
::: tip
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
Dictionary sync does not require a loaded Japanese subtitle track. Once Yomitan is ready, SubMiner can reuse an installed character dictionary even before the first subtitle is processed. If Yomitan readiness times out, the status reports a failure instead of staying on **Checking**.
:::
::: info
@@ -168,7 +170,7 @@ These phases are emitted through the configured notification surface. Some phase
2. **generating** - No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON.
3. MRU update (no notification) - add the media ID to the most-recently-used list and evict old entries beyond `maxLoaded`.
4. **building** - Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set - if it matches the previously imported revision, the import is skipped.
5. **importing** - Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
5. **importing** - Push the ZIP into Yomitan when the installed revision differs. Dictionary checks and settings updates wait for the extension to be ready, independently of subtitle processing. Readiness and quick operations have bounded timeouts; imports have a separate budget that grows with the ZIP size.
6. **ready** - Dictionary is live. Character names will match on the next subtitle line.
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
+31 -3
View File
@@ -61,7 +61,7 @@ The Settings window preserves existing JSONC comments, trailing commas, and unre
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.
Saving validates the candidate config before writing. Saving only fields marked **LIVE** shows "Saved. Live settings applied." If a save also changes fields that need a restart, the banner lists only the sections containing those changed fields. Live changes still apply in the same save.
## Configuration file
@@ -103,7 +103,7 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
shortcuts, notifications, logging level, selected source-language preferences,
Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
Jimaku/Subsync and subtitle-generation 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, and Kiku options
listed in the reference tables below.
@@ -157,6 +157,7 @@ The configuration file includes several main sections:
- [**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
- [**TMDB**](#tmdb) - Posters and synopses for live-action dramas and movies in the stats Library
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
@@ -978,6 +979,7 @@ This example is intentionally compact. The option table below documents availabl
| `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 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.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. |
| `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) |
@@ -998,7 +1000,7 @@ This example is intentionally compact. The option table below documents availabl
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `media.maxMediaDuration` | number (seconds) | Maximum generated clip duration for overlay and stats-dashboard mining. See the [configuration example](/config.example.jsonc) for the default and disabling the cap. |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
@@ -1188,6 +1190,32 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
### TMDB
TMDB (The Movie Database) supplies posters, synopses, and show grouping for live-action dramas and movies in the stats [Library](/immersion-tracking#library). AniList only covers anime, so TMDB is what gives live-action titles a cover and a description.
Release builds ship with a project TMDB key, so nothing needs to be configured. Set your own key to use your own quota, or when running SubMiner from source, where no key is bundled. Create one for free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api); either the short API key or the long "API Read Access Token" works.
```json
{
"tmdb": {
"apiKey": "",
"apiKeyCommand": "cat ~/.tmdb_key"
}
}
```
| Option | Values | Description |
| -------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output uses the bundled key when available and waits 30 seconds before the next request can retry the command. Changing either credential setting resets this cooldown.
Changes apply to the next TMDB request without a restart.
This product uses the TMDB API but is not endorsed or certified by TMDB.
### 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.
+37 -5
View File
@@ -31,12 +31,42 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
The same immersion data powers the stats dashboard.
The browser dashboard and in-app stats overlay both load from the local HTTP server.
The server accepts loopback hosts only and rejects requests from other browser origins,
including opaque origins such as `file://`. API clients without a browser origin can
still use the local API. Mutation requests with a body must use `application/json`;
bodyless deletion and Anki browse requests remain supported. Requests rejected by the
host or origin checks receive `403`; mutation bodies without a JSON content type
receive `415`.
Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale
Serve URLs are unsupported because their host or browser origin is not the local
server's origin. SSH stats synchronization is unchanged.
Scripts sending a JSON body must include the content type. For example, this
requests a duplicate-line cleanup preview without changing the database. Replace
the port if you configured a different `stats.serverPort`:
```bash
curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \
-H 'Content-Type: application/json' \
-d '{"dryRun":true}'
```
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
- 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.
SubMiner waits for the local server to bind before reporting that the dashboard is available. If another process already uses the configured port, the command reports the startup error and the desktop app stays open. Opening the in-app dashboard also reports startup failures through your configured status notifications.
`subminer stats -s` stops a background stats server or cancels a pending background start. It leaves a foreground-only server running, so an open in-app dashboard stays connected. Shutdown gives active HTTP requests one second to finish before closing their connections and finalizing stats.
### Stats API resource IDs
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
### Dashboard tabs
#### Overview
@@ -53,14 +83,16 @@ Local files and Jellyfin items with detected season numbers are split into seaso
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** / **Anime** / **Live Action** / **YouTube** selector above the grid narrows the Library to one kind, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry. Changing providers downloads the replacement cover before saving the new link; a failed download leaves the previous link and artwork intact. A title without a cover clears the previous artwork. Automatic TMDB matching leaves existing AniList links unchanged.
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use the kind selector above the grid (**All Titles**, **Anime**, **Live Action**, **YouTube**) to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations.
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. AniList-linked and TMDB-linked entries cannot be merged together, and YouTube channels cannot be merged with anime or live-action entries. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. Anime and live-action entries are interchangeable here, but a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
@@ -327,14 +359,14 @@ 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 (v23)
### Schema (v24)
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
Core tables:
- `imm_videos` - video key/title/source metadata
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables
- `imm_anime` - series or YouTube channel metadata referenced by videos and lifetime tables, including the media kind (`anime`, `live_action` or `youtube`) and the AniList or TMDB link
- `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
+3
View File
@@ -52,10 +52,13 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay.
On startup, SubMiner clears cached anime parser metadata containing both API-key text and Jellyfin stream markers. Metadata containing only one of these is preserved.
## Settings
All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting:
+14 -12
View File
@@ -1,6 +1,6 @@
# Jimaku integration
[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.
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime and Japanese live action, 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."
@@ -14,21 +14,23 @@ When you open the modal, SubMiner parses the current video filename to extract a
From there:
1. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names).
2. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
3. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
4. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
1. **Pick a catalogue** - The **Anime** and **Live action** tabs at the top of the modal choose which Jimaku catalogue to search. Switching tabs re-runs the current search. The choice persists until SubMiner restarts.
2. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of entries (Japanese and English names).
3. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
4. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
5. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
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
| 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 |
| `Arrow Left` / `Arrow Right` | Switch between the Anime and Live action tabs |
| `Escape` | Close modal |
## Configuration
@@ -52,7 +54,7 @@ Add a `jimaku` section to your `config.jsonc`:
| `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. |
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of entries returned per search. |
The keyboard shortcut is configured separately under `shortcuts`:
+13 -2
View File
@@ -547,7 +547,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// 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.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/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.
// ==========================================
@@ -568,6 +568,7 @@
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
@@ -596,7 +597,7 @@
"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.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -679,6 +680,16 @@
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// TMDB
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// Hot-reload: TMDB changes apply to the next TMDB request.
// ==========================================
"tmdb": {
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
+34 -6
View File
@@ -1,6 +1,6 @@
# 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.
Generate Japanese SRT subtitles from a local video's audio or an anime stream using [whisper.cpp](https://github.com/ggml-org/whisper.cpp). The launcher and overlay use the same local generation service. Transcription runs on your computer without uploading audio. Model downloads and stream audio extraction require an internet connection; local-file generation with an installed model does not.
## Setup
@@ -29,11 +29,21 @@ With speech detection configured, SubMiner keeps detected speech and other audib
Passages that fit within Whisper's 30-second audio window stay intact. Longer passages prefer nearby detected speech starts when choosing cuts, falling back to quiet pauses, with a small overlap to provide context. This reduces early subtitles caused by starting a clip well before its dialogue, while keeping all retained audio covered. Matching overlapping cues are combined even when punctuation differs; repeated dialogue at separate times remains separate. SubMiner runs each passage in a fresh Whisper process so decoder state from earlier audio cannot affect later passages. This reloads the model for each passage and can increase generation time. Subtitle cues stay within the supplied audio and retain each passage's position on the original timeline. Progress reports the current passage.
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. 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.
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to disable VAD for the session, or clear `vadModelPath` to change the default. Without a usable subtitle reference, disabling VAD returns to full-audio transcription. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
## Using loaded subtitles as timing references
When generating for the video currently open in mpv, SubMiner automatically looks for a dialogue subtitle track among its embedded subtitles and loaded external SRT, ASS/SSA, or WebVTT files. It prefers English, then tracks labeled full or dialogue. Forced tracks, image subtitles, generated subtitles, and tracks whose titles or filenames identify signs, songs, lyrics, karaoke, or opening/ending subtitles are skipped. These checks rely on metadata; an unlabeled signs-only file cannot always be identified.
The selected reference appears in generation progress. SubMiner reads its timestamps, including the active primary or secondary track's subtitle delay, and uses nearby cue starts to guide cuts in long audio passages. Short passages stay intact. The reference works with or without **Focus on spoken dialogue**. With that option enabled, reference starts take priority over VAD starts when choosing a nearby cut; VAD still helps identify speech. Audio outside reference cues remains eligible for transcription, and Whisper still supplies the Japanese text and final timestamps. The reference is assumed to be timed for the playing video; this does not automatically sync a mistimed reference.
Unreadable or empty references are skipped in favor of another eligible loaded track. If none can be read, generation uses its normal audio timing. The launcher uses loaded references only when its input matches the video currently open in mpv; standalone generation keeps its existing behavior. It captures reference tracks and delays together with the initial audio selection, before checking or downloading a model. When relying on mpv's selected audio, it stops and asks you to retry if the media changes or cannot be verified during capture.
## 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 modal recommends **large-v3-turbo** when it detects an NVIDIA GPU through `nvidia-smi` and the selected `whisper-cli` discovers an available CUDA device. Otherwise it recommends **small** for a balance of Japanese recognition quality and CPU time. The check works before downloading a model and falls back to small if a tool is missing, fails, times out, or reports an unrecognized result. Vulkan, AMD, and Apple GPU support do not qualify for the turbo recommendation. Checks are cached for up to 30 seconds; changing the Whisper executable path triggers a new check.
The recommendation labels the model picker and explains the detected support. It does not change your configured model, current selection, external model path, or launcher's model choice. It also does not force a GPU backend during transcription. These are starting recommendations, not hardware benchmarks or guarantees that every model fits in available GPU memory. 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).
@@ -41,18 +51,36 @@ A configured external Model Path takes precedence and hides the managed model pi
## From the overlay
1. Open a local video in mpv and select its Japanese audio track.
1. Open a local video or an anime episode stream 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.
The modal adapts to the player window, using a wider layout when space allows and scrolling in smaller windows. It 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.
### Generating for anime streams
Start the episode from the [anime browser](/anime-browser), then open the generation modal as usual. HTTP and HTTPS episode streams, including HLS through the anime browser's proxy, can use the same models and dialogue options as local files. The selected audio may be inside the stream or a separately attached HTTP/HTTPS audio track. For a selected external audio stream, SubMiner downloads that track directly and includes mpv's current audio delay in the subtitle timing. Local external audio files remain unsupported.
SubMiner fetches the episode's audio into a temporary WAV before transcription. It reuses mpv's supported request headers, referrer, and user agent. The existing media-fetch policy excludes Cookie and Authorization headers, so sources requiring those directly may fail unless the anime bridge handles authentication. Expired URLs or a stopped bridge can also interrupt extraction; reopen the episode and retry.
For anime-browser episodes, extraction first considers separate Japanese audio tracks returned by the source, then video streams with lower resolution than playback. It checks duration, starting offset, and short audio samples at three points against the selected playback audio. Different dialogue, shifted timing, silence, or failed checks keep the original stream. Checking alternatives has a 20-second budget. For plain HLS, it downloads only the segments covering each sample and aligns their timestamps to playback before comparing audio. Empty original samples are retried once and never reused. Failed checks log the candidate number, phase, reason, and elapsed time without exposing stream URLs or request headers. Progress names the source used for extraction; playback keeps its selected quality. Sources that expose only one combined stream still require downloading its video bytes.
For plain HLS episodes with MPEG-TS segments, SubMiner downloads up to four segments at once, then extracts audio in playback order. Progress shows segment downloading followed by audio extraction. The temporary segments require space for the episode's combined video and audio and are removed after extraction, or when generation fails or is cancelled. Encrypted playlists, variant playlists, byte ranges, and other complex stream formats use the existing FFmpeg extraction path. Download speed still depends on the source and proxy.
Generated SRT files are retained under `cache/generated-subtitles` in SubMiner's application data directory, using a URL hash for the filename and a numeric suffix for repeated generation. The result shows the saved path. Playback can continue during generation; the completed SRT is selected only if the same stream is still playing. Cached SRT results are not automatically loaded on a later visit.
Completed audio extracts can be reused for retries and model changes during the current app session. They live with temporary job files under `cache/generated-subtitles/sessions`, separate from saved SRT files. The cache distinguishes the stream URL, selected audio track, and request headers, verifies contents before reuse, and evicts older audio above 512 MiB. Partial downloads are not cached.
Quitting cancels the active job, waits for its cleanup, and removes the session's downloaded audio and working files. On startup, SubMiner removes abandoned session directories from processes that have exited, plus audio retained by the older persistent cache. Audio is never reused across app restarts. Generated SRT files and installed speech models remain available.
This processes the complete episode, so subtitles appear after extraction and transcription finish. Streams must report a finite duration; live streams are not supported. A stream with combined video and audio segments may require downloading those segments even though only audio is retained.
## From the launcher
```bash
@@ -67,4 +95,4 @@ With no file argument, the command uses the current local mpv media and its sele
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.
The overlay supports local files and finite HTTP/HTTPS streams with internal or selected external HTTP audio tracks. The launcher remains local-file only. Subtitle translation and separately attached local audio files 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. Cached local subtitle tracks and embedded text subtitles can guide stream transcription; unreadable references fall back to audio timing.
+4
View File
@@ -93,6 +93,10 @@ 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.
When the input matches the current mpv file, generation automatically uses an eligible embedded
or loaded external subtitle track as a timing reference, preferring English dialogue and skipping
tracks marked as signs, songs, or forced. See [timing references](/subtitle-generation#using-loaded-subtitles-as-timing-references).
```bash
subminer generate-subs video.mkv --download-model
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
+4
View File
@@ -149,6 +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.
## Viewing stats
The stats Library groups tracked YouTube videos by channel when channel metadata is available. Select **YouTube** in the Library filter to see those channels separately from anime. Each channel page lists its videos, watch time, vocabulary, and mined cards. See [Immersion Tracking](/immersion-tracking#library).
## Related pages
- [Usage - YouTube Playback](/usage#youtube-playback)
+4 -1
View File
@@ -15,7 +15,10 @@
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
Both callers explicitly pass the five required macOS signing/notarization
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow.
secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that
`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`;
artifacts built without it simply require users to set `tmdb.apiKey`).
`GITHUB_TOKEN` remains automatically available to the reusable workflow.
Each platform verifies its ASAR and external resources before signing, then
measures the signed app and installers before upload. Missing runtime assets,
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
+4 -1
View File
@@ -30,8 +30,11 @@ Update checks and startup launcher migration share a serialized update-state sto
- `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/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `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 dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. The overlay also accepts finite HTTP/HTTPS episode streams, snapshots mpv's supported HTTP headers, and extracts audio through FFmpeg with bounded network reads. Network reference extraction uses the same headers. Generated stream subtitles persist under the injected application cache directory with hashed filenames. `subtitle-generation-audio.ts` owns probing, extraction, and completed-audio reuse. Anime playback retains bounded per-URL alternative-source metadata; generation checks candidate duration, offset, and decoded audio samples before selecting separate Japanese audio or lower-resolution video. Verified extracts are reused only within the current app session, keyed by playback URL, audio stream index, and request headers. `subtitle-generation-session.ts` owns temporary audio and job directories, removes abandoned process directories on startup, and drains cancellation before shutdown cleanup. Saved SRT files remain separate. Plain HLS samples use short segment windows and container timestamps to avoid empty remote seeks; source rejection diagnostics omit URLs and headers. The launcher remains local-file only. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/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.
+1
View File
@@ -30,6 +30,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- 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
+28 -3
View File
@@ -22,9 +22,15 @@ Read when: selecting the right verification lane for a change
pull requests, stable tags, and prerelease tags. Keep common quality steps
there instead of copying them into caller workflows.
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
plugin tests run for every pull request and tagged release.
plugin tests and launcher smoke run for every pull request and tagged release.
Lua installation uses only the runner's Ubuntu package sources so unrelated
third-party repository failures do not block the gate.
- In the reusable gate, `test:coverage:src` is also the blocking execution of the
discovered `src/**` test lane. The coverage runner returns the failing test's
status, so CI does not rerun that lane through `test:fast`. Launcher unit and
script tests still run separately because they are outside the coverage lane.
- Launcher smoke artifacts are uploaded after `test:env` fails. CI does not rerun
launcher smoke solely to collect the same artifacts.
## Default Handoff Gate
@@ -49,7 +55,7 @@ bun run docs:build
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
@@ -62,11 +68,30 @@ bun run docs:build
## Coverage Reporting
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
`test:src` through a sharded coverage runner: one Bun coverage process per test
file, then merged LCOV output.
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
source test gate, not a report-only step.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
- Every reusable quality-gate run uploads that LCOV file as the
`coverage-test-src` artifact.
## Compiled Runtime Smoke
- `bun run test:smoke:dist` and its `test:runtime:compat` alias require an existing
full build and fail with the missing artifact paths when `dist/` or the stats UI
bundle is absent.
- The check runs the emitted stats daemon under Electron's Node runtime. It opens
the production HTTP server, queries the overview endpoint through native
libsql-backed storage, and leaves an HTTP request body unfinished before
shutting the daemon down. It verifies a clean exit and that the port and
ownership state are released despite the unfinished request.
- The check also occupies the configured port, requires startup to fail without
stale ownership state, releases the conflict, and verifies a clean retry.
- This is not a full Electron UI startup check. It does not require a display and
makes no claims about renderer, window, tray, or mpv behavior.
## Dependency Audit Policy
- `bun audit --audit-level high` blocks the reusable quality gate.
@@ -136,25 +136,28 @@ test('current mpv generation requires an identifiable selected audio track', asy
}
});
test('explicit local file leaves Japanese track selection to the shared generator', async () => {
test('explicit playing file leaves audio selection to the generator but inspects subtitle references', 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,
true,
);
});
test('launcher does not load generated subtitles after mpv switches files', async () => {
const f = fixture(['generate-subs']);
let pathRequests = 0;
let changed = false;
f.deps.mpvCommand = async (_socket, command) => {
f.commands.push(command);
if (command[1] === 'path')
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
if (command[1] === 'path') return changed ? '/media/next.mkv' : '/media/episode.mkv';
return [{ type: 'audio', selected: true, 'ff-index': 2 }];
};
f.deps.generate = async () => {
changed = true;
return '/media/episode.ja.srt';
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(
f.commands.some((command) => command[0] === 'sub-add'),
@@ -163,6 +166,86 @@ test('launcher does not load generated subtitles after mpv switches files', asyn
assert.match(f.output.join(''), /Saved Japanese subtitles/);
});
test('current mpv generation rejects tracks when playback changes during capture', async () => {
for (const nextMedia of ['/media/next.mkv', null]) {
const f = fixture(['generate-subs']);
let media: string | null = '/media/episode.mkv';
let modelChecks = 0;
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return media;
if (command[1] === 'track-list') {
media = nextMedia;
return [{ type: 'audio', selected: true, 'ff-index': 99 }];
}
return undefined;
};
f.deps.resolveModel = async () => {
modelChecks += 1;
return { kind: 'external', path: '/models/model.bin' };
};
await assert.rejects(
runGenerateSubtitlesCommand(f.context, f.deps),
/mpv media.*Run generate-subs again/,
);
assert.equal(f.generations.length, 0);
assert.equal(modelChecks, 0);
}
});
test('explicit media or audio stream remains usable when the mpv snapshot changes', async () => {
for (const options of [['/media/episode.mkv'], ['--audio-stream', '7']]) {
const f = fixture(['generate-subs', ...options]);
let media = '/media/episode.mkv';
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return media;
if (command[1] === 'track-list') {
media = '/media/next.mkv';
return [
{ type: 'audio', selected: true, 'ff-index': 99 },
{ type: 'sub', lang: 'eng', 'ff-index': 100 },
];
}
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv');
assert.equal(
f.generations[0]?.audioStreamIndex,
options[0] === '--audio-stream' ? 7 : undefined,
);
assert.deepEqual(f.generations[0]?.references, []);
}
});
test('launcher captures loaded external references only for the matching media', async () => {
for (const matching of [true, false]) {
const f = fixture();
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return matching ? '/media/episode.mkv' : '/media/other.mkv';
if (command[1] === 'working-directory') return '/mpv';
if (command[1] === 'track-list')
return [
{ type: 'sub', external: true, 'external-filename': 'episode.en.signs.ass' },
{ type: 'sub', external: true, 'external-filename': 'episode.en.srt' },
];
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.deepEqual(
f.generations[0]?.references,
matching
? [
{
label: 'episode.en.srt',
delaySeconds: 0,
source: { kind: 'external', path: '/mpv/episode.en.srt' },
},
]
: [],
);
}
});
test('explicit managed model overrides external config and downloads before generation', async () => {
const f = fixture([
'generate-subs',
@@ -191,6 +274,69 @@ test('explicit managed model overrides external config and downloads before gene
assert.equal(f.generations.length, 1);
});
test('audio and subtitle timing use the initial mpv snapshot across model setup', async () => {
for (const changeDuring of ['resolve', 'download']) {
const f = fixture(['generate-subs', '--download-model']);
let changed = false;
let trackReads = 0;
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return '/media/episode.mkv';
if (command[1] === 'track-list') {
trackReads += 1;
return [
{ type: 'audio', selected: true, 'ff-index': changed ? 3 : 2 },
{ type: 'sub', id: 1, title: 'English Full', lang: 'eng', 'ff-index': changed ? 5 : 4 },
];
}
if (command[1] === 'sid') return 1;
if (command[1] === 'sub-delay') return changed ? 9 : 1.5;
return undefined;
};
f.deps.resolveModel = async () => {
if (changeDuring === 'resolve') changed = true;
return { kind: 'missing', path: '/models/model.bin' };
};
f.deps.downloadModel = async () => {
changed = true;
return '/models/model.bin';
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, 2);
assert.deepEqual(f.generations[0]?.references, [
{
label: 'English Full',
delaySeconds: 1.5,
source: { kind: 'embedded', streamIndex: 4 },
},
]);
assert.equal(trackReads, 1);
}
});
test('explicit audio stream bypasses mpv audio selection while retaining subtitle references', async () => {
const f = fixture(['generate-subs', '--audio-stream', '7']);
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return '/media/episode.mkv';
if (command[1] === 'track-list')
return [
{ type: 'audio', selected: true, external: true, 'ff-index': 0 },
{ type: 'sub', id: 2, title: 'English Full', lang: 'eng', 'ff-index': 4 },
];
if (command[1] === 'secondary-sid') return 2;
if (command[1] === 'secondary-sub-delay') return -0.5;
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, 7);
assert.deepEqual(f.generations[0]?.references, [
{
label: 'English Full',
delaySeconds: -0.5,
source: { kind: 'embedded', streamIndex: 4 },
},
]);
});
test('generation can run standalone and never loads subtitles into another video', async () => {
for (const playing of [null, '/media/different.mkv']) {
const f = fixture();
+30 -10
View File
@@ -8,6 +8,10 @@ import {
resolveSubtitleGenerationTools,
} from '../../src/core/services/subtitle-generation.js';
import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js';
import {
readSubtitleGenerationReferences,
type SubtitleGenerationReference,
} from '../../src/core/services/subtitle-generation-reference.js';
import {
resolveSubtitleGenerationConfig,
type SubtitleGenerationProgress,
@@ -69,12 +73,9 @@ async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['
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 : []) {
function selectedMpvAudioStream(value: unknown) {
const tracks: unknown[] = Array.isArray(value) ? value : [];
for (const track of tracks) {
if (
typeof track === 'object' &&
track !== null &&
@@ -160,11 +161,29 @@ export async function runGenerateSubtitlesCommand(
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.');
// Capture audio and subtitle timing together before model setup can yield to playback changes.
const matchesCurrentMedia = currentMedia !== null && sameFile(currentMedia, mediaPath);
const tracks = matchesCurrentMedia
? await deps
.mpvCommand(context.mpvSocketPath, ['get_property', 'track-list'], 1000)
.catch(() => null)
: null;
let references: SubtitleGenerationReference[] = [];
if (matchesCurrentMedia) {
const candidates = await readSubtitleGenerationReferences(tracks, (name) =>
deps.mpvCommand(context.mpvSocketPath, ['get_property', name], 1000),
);
const stillPlaying = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
() => null,
);
if (stillPlaying && sameFile(stillPlaying, mediaPath)) references = candidates;
else if (!options.mediaPath && options.audioStreamIndex === undefined)
throw new Error(
'The current mpv media changed or could not be verified while reading tracks. Run generate-subs again.',
);
}
const audioStreamIndex =
options.audioStreamIndex ??
(!options.mediaPath
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
: undefined);
options.audioStreamIndex ?? (!options.mediaPath ? selectedMpvAudioStream(tracks) : undefined);
const onProgress = createGenerationProgressReporter(write);
// Missing executables fail here, before any model download starts.
requireSubtitleGenerationTools(await deps.resolveTools(config));
@@ -183,6 +202,7 @@ export async function runGenerateSubtitlesCommand(
modelDirectory,
mediaPath,
audioStreamIndex,
references,
outputPath: options.outputPath
? path.resolve(resolvePathMaybe(options.outputPath))
: undefined,
@@ -31,6 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [
'imm_lifetime_applied_sessions',
'imm_stats_excluded_words',
'idx_anime_normalized_title',
'idx_anime_namespace_title',
'idx_anime_anilist_id',
'idx_videos_anime_id',
'idx_sessions_video_started',
+5 -1
View File
@@ -12,7 +12,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
);
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE,
normalized_title_key TEXT NOT NULL,
canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE,
title_romaji TEXT,
@@ -20,10 +20,14 @@ export const IMMERSION_DB_FIXTURE_DDL = `
title_native TEXT,
episodes_total INTEGER,
description TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime',
tmdb_id INTEGER,
tmdb_type TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
CREATE UNIQUE INDEX idx_anime_namespace_title ON imm_anime((media_kind = 'youtube'), normalized_title_key);
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE,
+2 -2
View File
@@ -52,7 +52,7 @@
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
"test:smoke:dist": "env ELECTRON_RUN_AS_NODE=1 electron scripts/compiled-runtime-smoke.mjs",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
@@ -64,7 +64,7 @@
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
"test:stats": "bun scripts/run-test-lane.mjs stats",
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
"test:runtime:compat": "bun run test:smoke:dist",
"test": "bun run test:fast",
"test:config": "bun scripts/run-test-lane.mjs config",
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
+29
View File
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds carry a project-owned TMDB key so live-action lookups work
* without user setup. The key is injected from the SUBMINER_TMDB_API_KEY
* environment variable at build time (a GitHub Actions secret in CI) and never
* lives in the repository. The runtime reader is
* src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY';
/**
* Write the bundled keys file into `distDir`, or remove a stale one when no
* key is present so a keyless build never ships an older key by accident.
* Returns the names of the keys staged.
*/
export function stageBundledIntegrationKeys(distDir, env = process.env) {
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? '';
if (!tmdbApiKey) {
fs.rmSync(outputPath, { force: true });
return [];
}
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 });
return ['tmdb'];
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
BUNDLED_INTEGRATION_KEYS_FILENAME,
stageBundledIntegrationKeys,
} from './bundled-integration-keys.mjs';
function withDistDir(work: (distDir: string) => void): void {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-'));
try {
work(distDir);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
}
test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => {
withDistDir((distDir) => {
const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' });
assert.deepEqual(staged, ['tmdb']);
const written = JSON.parse(
fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'),
);
assert.deepEqual(written, { tmdbApiKey: 'abc123' });
});
});
test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => {
withDistDir((distDir) => {
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}');
assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []);
assert.equal(fs.existsSync(outputPath), false);
assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []);
});
});
+345
View File
@@ -0,0 +1,345 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const START_TIMEOUT_MS = 15_000;
const STOP_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 25;
const MAX_CHILD_OUTPUT_BYTES = 64 * 1024;
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRootArgIndex = process.argv.indexOf('--repo-root');
const repoRoot = resolve(
repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''),
);
const paths = {
mainEntry: join(repoRoot, 'dist', 'main-entry.js'),
daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'),
statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'),
tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'),
statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'),
};
function requireCompiledArtifacts() {
const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath));
if (missing.length > 0) {
throw new Error(
`Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing
.map((artifactPath) => ` - ${artifactPath}`)
.join('\n')}`,
);
}
}
function requireElectronNodeRuntime() {
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') {
throw new Error(
'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.',
);
}
}
function delay(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}
function listen(server, port = 0) {
return new Promise((resolvePromise, reject) => {
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Could not resolve the stats smoke port.'));
return;
}
resolvePromise(address.port);
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, '127.0.0.1');
});
}
function closeServer(server) {
return new Promise((resolvePromise, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolvePromise();
});
});
}
async function findAvailablePort() {
const reservation = createServer();
const port = await listen(reservation);
await closeServer(reservation);
return port;
}
function writeSmokeConfig(userDataPath, port) {
writeFileSync(
join(userDataPath, 'config.json'),
`${JSON.stringify({ stats: { serverPort: port } })}\n`,
);
}
function spawnDaemon(userDataPath, responsePath) {
const child = spawn(
process.execPath,
[
paths.daemonEntry,
'--stats-user-data-path',
userDataPath,
'--stats-response-path',
responsePath,
],
{
cwd: repoRoot,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let output = '';
const appendOutput = (chunk) => {
if (output.length >= MAX_CHILD_OUTPUT_BYTES) return;
output += String(chunk);
if (output.length > MAX_CHILD_OUTPUT_BYTES) {
output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`;
}
};
child.stdout.on('data', appendOutput);
child.stderr.on('data', appendOutput);
let exited = false;
const exit = new Promise((resolvePromise) => {
child.once('error', (error) => {
exited = true;
resolvePromise({ code: null, signal: null, error });
});
child.once('exit', (code, signal) => {
exited = true;
resolvePromise({ code, signal, error: null });
});
});
return { child, exit, hasExited: () => exited, getOutput: () => output };
}
async function waitForResponse(responsePath, daemon) {
const deadline = Date.now() + START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (existsSync(responsePath)) {
try {
return JSON.parse(readFileSync(responsePath, 'utf8'));
} catch {
// The daemon may still be finishing the response file write.
}
}
if (daemon.hasExited()) {
const result = await daemon.exit;
throw new Error(
`Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`,
);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`);
}
function formatExit(result) {
if (result.error) return result.error.message;
if (result.signal) return `signal ${result.signal}`;
return `exit code ${result.code}`;
}
async function waitForExit(daemon, timeoutMs) {
return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]);
}
async function stopDaemon(daemon) {
if (daemon.hasExited()) {
return await daemon.exit;
}
daemon.child.kill('SIGTERM');
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
if (result) return result;
daemon.child.kill('SIGKILL');
await daemon.exit;
throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`);
}
async function assertPortCanBind(port) {
const server = createServer();
try {
await listen(server, port);
} finally {
if (server.listening) await closeServer(server);
}
}
async function fetchOverview(url, daemon) {
const deadline = Date.now() + START_TIMEOUT_MS;
let lastError = null;
while (Date.now() < deadline) {
try {
return await fetch(`${url}/api/stats/overview`, {
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
} catch (error) {
lastError = error;
}
if (daemon.hasExited()) {
const result = await daemon.exit;
throw new Error(
`Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`,
);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(
`Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`,
);
}
async function runHealthyStartup(userDataPath, port, responseName) {
writeSmokeConfig(userDataPath, port);
const responsePath = join(userDataPath, responseName);
const statePath = join(userDataPath, 'stats-daemon.json');
const databasePath = join(userDataPath, 'immersion.sqlite');
const daemon = spawnDaemon(userDataPath, responsePath);
let shutdownResult;
let unfinishedRequest;
try {
const startup = await waitForResponse(responsePath, daemon);
assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` });
const response = await fetchOverview(startup.url, daemon);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/);
const overview = await response.json();
assert.equal(typeof overview, 'object');
assert.ok(overview !== null);
assert.ok(Array.isArray(overview.sessions));
assert.ok(Array.isArray(overview.rollups));
assert.equal(typeof overview.hints, 'object');
const dashboard = await fetch(`${startup.url}/?overlay=1`, {
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
assert.equal(dashboard.status, 200);
assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/);
assert.match(await dashboard.text(), /id="root"/);
assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.');
assert.ok(
statSync(databasePath).size > 0,
'The compiled tracker created an empty SQLite file.',
);
// Leave a real request body unfinished so shutdown must bound its drain wait.
unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '1000',
Expect: '100-continue',
},
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
await new Promise((resolvePromise, reject) => {
unfinishedRequest.on('error', reject);
unfinishedRequest.once('continue', () => {
unfinishedRequest.write('{');
resolvePromise();
});
unfinishedRequest.flushHeaders();
});
} finally {
try {
shutdownResult = await stopDaemon(daemon);
} finally {
unfinishedRequest?.destroy();
}
}
assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`);
assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.');
await assertPortCanBind(port);
}
async function runConflictRecovery() {
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-'));
const reservation = createServer();
const port = await listen(reservation);
const responsePath = join(userDataPath, 'conflict-response.json');
const statePath = join(userDataPath, 'stats-daemon.json');
writeSmokeConfig(userDataPath, port);
const daemon = spawnDaemon(userDataPath, responsePath);
const failures = [];
try {
const response = await waitForResponse(responsePath, daemon);
if (response.ok !== false) {
failures.push('The stats daemon reported success while its configured port was occupied.');
}
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
if (!result) {
failures.push('The stats daemon did not exit after its configured port failed to bind.');
} else if (result.code === 0) {
failures.push(
'The stats daemon exited successfully after its configured port failed to bind.',
);
}
if (existsSync(statePath)) {
failures.push(
'The stats daemon left ownership state behind after its configured port failed to bind.',
);
}
} finally {
await closeServer(reservation);
if (!daemon.hasExited()) {
await stopDaemon(daemon);
}
}
try {
await runHealthyStartup(userDataPath, port, 'recovery-response.json');
} finally {
rmSync(userDataPath, { recursive: true, force: true });
}
assert.deepEqual(failures, []);
}
async function main() {
requireCompiledArtifacts();
requireElectronNodeRuntime();
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-'));
try {
await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json');
} finally {
rmSync(userDataPath, { recursive: true, force: true });
}
await runConflictRecovery();
process.stdout.write(
`Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`,
);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
test('compiled runtime smoke fails clearly when build artifacts are missing', () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-'));
try {
const result = spawnSync(
process.execPath,
['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo],
{
cwd: path.resolve(import.meta.dir, '..'),
encoding: 'utf8',
},
);
assert.equal(result.status, 1);
assert.match(result.stderr, /Compiled runtime artifacts are missing/);
assert.match(result.stderr, /dist\/main-entry\.js/);
assert.match(result.stderr, /dist\/stats-daemon-runner\.js/);
assert.doesNotMatch(result.stderr, /must run with Electron/);
} finally {
fs.rmSync(emptyRepo, { recursive: true, force: true });
}
});
+13
View File
@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
@@ -111,6 +112,17 @@ function buildMacosHelper() {
}
}
// Only the key names are logged, never the values: CI masks secrets, but a
// stray echo would still leak them into local build logs.
function stageIntegrationKeys() {
const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist'));
process.stdout.write(
staged.length > 0
? `Staged bundled integration keys: ${staged.join(', ')}\n`
: `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`,
);
}
function main() {
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
recursive: true,
@@ -121,6 +133,7 @@ function main() {
copySyncUiAssets();
copyAnimeUiAssets();
buildMacosHelper();
stageIntegrationKeys();
}
main();
+34 -2
View File
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import test from 'node:test';
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([
@@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
});
test('runCoverageLane returns a failure when a discovered test fails', () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
try {
mkdirSync(join(repoRoot, 'src'));
writeFileSync(
join(repoRoot, 'src', 'failure.test.ts'),
[
"import assert from 'node:assert/strict';",
"import test from 'node:test';",
'',
"test('intentional coverage failure', () => {",
" assert.fail('coverage runner must propagate this failure');",
'});',
'',
].join('\n'),
);
assert.notEqual(
runCoverageLane({
repoRootDir: repoRoot,
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
stdio: 'pipe',
}),
0,
);
} finally {
rmSync(repoRoot, { recursive: true, force: true });
}
});
+11 -7
View File
@@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string {
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
}
function runCoverageLane(): number {
const laneName = process.argv[2];
export function runCoverageLane(
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
): number {
const repoRootDir = options.repoRootDir ?? repoRoot;
const argv = options.argv ?? process.argv.slice(2);
const laneName = argv[0];
if (laneName === undefined) {
process.stderr.write('Missing coverage lane name\n');
return 1;
}
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1));
const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true });
@@ -216,7 +220,7 @@ function runCoverageLane(): number {
let files: string[];
try {
files = collectLaneFiles(repoRoot, laneName);
files = collectLaneFiles(repoRootDir, laneName);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
return 1;
@@ -230,8 +234,8 @@ function runCoverageLane(): number {
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRoot,
stdio: 'inherit',
cwd: repoRootDir,
stdio: options.stdio ?? 'inherit',
},
);
@@ -253,7 +257,7 @@ function runCoverageLane(): number {
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
);
return 0;
} finally {
+75 -1
View File
@@ -20,6 +20,8 @@ function createHarness(overrides?: {
timeoutMs?: number;
probeIntervalMs?: number;
readCostMs?: number;
onWait?: (elapsedMs: number, harness: Harness) => void;
isCurrent?: () => boolean;
}) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>();
@@ -39,10 +41,12 @@ function createHarness(overrides?: {
},
wait: async (ms) => {
clock += ms;
overrides?.onWait?.(clock, harness);
},
now: () => clock,
timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
isCurrent: overrides?.isCurrent,
});
const harness: Harness = {
@@ -94,6 +98,76 @@ test('times out with a failure when nothing ever starts', async () => {
watch.dispose();
});
test('waits for a slow stream that is still loading after the confirmation deadline', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.setProperty('vo-configured', true);
},
});
harness.setProperty('idle-active', false);
assert.deepEqual(await watch.wait(), { ok: true });
assert.equal(harness.elapsed(), 600);
watch.dispose();
});
test('reports a real stream error after the confirmation deadline', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.emitEndFile({ reason: 'error', fileError: 'HTTP 503' });
},
});
harness.setProperty('idle-active', false);
assert.deepEqual(await watch.wait(), {
ok: false,
error: 'mpv could not play this stream: HTTP 503',
});
watch.dispose();
});
test('stops waiting if a slow stream leaves mpv idle', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.setProperty('idle-active', true);
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
watch.dispose();
});
test('stops watching a slow stream when the request is superseded', async () => {
let current = true;
const { watch, harness } = createHarness({
timeoutMs: 300,
isCurrent: () => current,
onWait: (elapsedMs) => {
if (elapsedMs >= 600) current = false;
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
watch.dispose();
assert.equal(harness.listenerCount(), 0);
});
test('disposing the watcher stops polling a slow stream', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs) => {
if (elapsedMs >= 600) watch.dispose();
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
assert.equal(harness.listenerCount(), 0);
});
test('slow property reads eat the budget instead of extending it', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
@@ -102,7 +176,7 @@ test('slow property reads eat the budget instead of extending it', async () => {
});
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
// Property reads and the final idle check all count toward elapsed time.
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
watch.dispose();
+24 -8
View File
@@ -26,10 +26,12 @@ export interface WatchPlaybackOutcomeDeps {
/** 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. */
/** Injectable clock for the initial confirmation deadline. */
now?: () => number;
timeoutMs?: number;
probeIntervalMs?: number;
/** Stop watching when a newer episode replaces this request or the app closes. */
isCurrent?: () => boolean;
}
export interface PlaybackOutcomeWatch {
@@ -49,6 +51,7 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
let failure: PlaybackOutcome | null = null;
let disposed = false;
const unsubscribe = deps.onEndFile((event) => {
if (event.reason !== 'error') return;
failure = {
@@ -60,11 +63,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
});
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.
// Use elapsed time rather than probe count before checking whether mpv is
// still active. Slow property reads count toward this initial deadline.
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
while (now() < deadline) {
while (!disposed && (deps.isCurrent?.() ?? true)) {
if (failure) return failure;
try {
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
@@ -72,10 +75,17 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
// 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.
// A deadline without video is not a failure while mpv is still opening
// the stream. Keep waiting for video or a real end-file error in that case.
const remaining = deadline - now();
if (remaining <= 0) break;
await deps.wait(Math.min(probeIntervalMs, remaining));
if (remaining <= 0) {
try {
if ((await deps.readProperty('idle-active')) !== false) break;
} catch {
break;
}
}
await deps.wait(remaining > 0 ? Math.min(probeIntervalMs, remaining) : probeIntervalMs);
}
return (
failure ?? {
@@ -85,5 +95,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
);
}
return { wait, dispose: unsubscribe };
return {
wait,
dispose: () => {
disposed = true;
unsubscribe();
},
};
}
+18
View File
@@ -1527,3 +1527,21 @@ test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key quer
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('api_key='), false);
});
test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never,
{} as never,
{
currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
currentMediaTitle: 'stream?static=true&api_key=test-secret',
currentTimePos: 426,
send: () => true,
} as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
});
+13 -6
View File
@@ -32,6 +32,7 @@ import {
type MediaTimingReviewRequest,
} from './types/anki';
import { AiConfig } from './types/integrations';
import { sanitizeMediaTitle } from './shared/media-identity';
import type { KnownWordMaturityTier } from './types/subtitle';
import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
@@ -1117,9 +1118,11 @@ export class AnkiIntegration {
return null;
}
const mediaRange = this.getSubtitleMediaRange(context);
const timestamp = context
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
: this.mpvClient.currentTimePos || 0;
const timestamp =
context?.screenshotTime ??
(context
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
: this.mpvClient.currentTimePos || 0);
if (this.config.media?.imageType === 'avif') {
return this.mediaGenerator.generateAnimatedImage(
@@ -1165,11 +1168,13 @@ export class AnkiIntegration {
}
const videoFilename = extractFilenameFromMediaPath(mediaPath);
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const resolvedMediaTitle = sanitizeMediaTitle(mediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? resolvedMediaTitle || videoFilename
: videoFilename || resolvedMediaTitle) || fallbackFilename;
? resolvedMediaTitle || 'Unknown media'
: sanitizeMediaTitle(videoFilename) || resolvedMediaTitle) ||
sanitizeMediaTitle(fallbackFilename) ||
'Unknown media';
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
@@ -1794,6 +1799,8 @@ export class AnkiIntegration {
...request,
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
screenshotEnabled:
this.config.media.generateImage !== false && this.config.media.imageType !== 'avif',
});
}
@@ -14,7 +14,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
audio: 'SentenceAudio',
wordAudio: 'Pronunciation',
},
media: {
imageType: 'avif',
@@ -25,7 +26,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
SentenceAudio: { value: '[sound:sentence.mp3]' },
Pronunciation: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
@@ -121,3 +123,32 @@ test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled
assert.equal(leadInSeconds, 0);
});
for (const sentenceAudio of ['', '[sound:sentence.mp3]']) {
test(`word audio defaults independently of sentence audio (${sentenceAudio ? 'existing' : 'new'} note)`, async () => {
const retrieved: string[] = [];
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: { audio: 'SentenceAudio' },
media: { imageType: 'avif' },
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: { value: '[sound:word.mp3]' },
SentenceAudio: { value: sentenceAudio },
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name !== undefined && name in noteInfo.fields) ?? null,
retrieveMediaFileBase64: async (filename) => {
retrieved.push(filename);
return 'd29yZA==';
},
probeAudioDurationSeconds: async (_buffer, filename) => (filename === 'word.mp3' ? 0.6 : 4),
});
assert.equal(leadInSeconds, 0.6);
assert.deepEqual(retrieved, ['word.mp3']);
});
}
+2 -2
View File
@@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
const wordAudioFieldName = resolveConfiguredFieldName(
noteInfo,
config.fields?.audio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
config.fields?.wordAudio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.wordAudio,
);
if (!wordAudioFieldName) {
return 0;
@@ -160,6 +160,52 @@ test('manual clipboard subtitle update replaces audio in the configured field',
);
});
test('manual clipboard mining treats a zero media duration cap as unlimited', async () => {
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 14 },
{ maxMediaDuration: 1, expectedEnd: 13 },
];
for (const scenario of scenarios) {
const { service } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
mediaGenerator: {
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
await service.updateLastAddedFromClipboard('字幕');
assert.deepEqual(audioRanges.at(-1), {
start: 12,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
+20 -3
View File
@@ -290,6 +290,9 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
});
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
let reviewing = false;
const audioRanges: number[][] = [];
const imageTimes: number[] = [];
const audioPaths: string[] = [];
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
@@ -319,6 +322,10 @@ test('CardCreationService uses stream-open-filename for remote media generation'
behavior: {},
ai: false,
}) as AnkiConnectConfig,
reviewMediaTiming: async () =>
reviewing
? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 }
: { action: 'use-original' },
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
@@ -349,16 +356,18 @@ test('CardCreationService uses stream-open-filename for remote media generation'
],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [],
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path) => {
generateAudio: async (path, start, end, padding) => {
audioRanges.push([start, end, padding ?? -1]);
audioPaths.push(recordMediaPath(path));
return Buffer.from('audio');
},
generateScreenshot: async (path) => {
generateScreenshot: async (path, timestamp) => {
imageTimes.push(timestamp);
imagePaths.push(recordMediaPath(path));
return Buffer.from('image');
},
@@ -406,6 +415,14 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.equal(created, true);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
reviewing = true;
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
await service.markLastCardAsAudioCard();
assert.equal(imageTimes.length, 3);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
});
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
+17 -10
View File
@@ -21,6 +21,7 @@ import {
resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions,
} from './media-source';
import { clampMediaEndTime } from './media-duration';
import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume';
@@ -233,11 +234,12 @@ export class CardCreationService {
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) {
const cappedRangeEnd = clampMediaEndTime(rangeStart, rangeEnd, maxMediaDuration);
if (cappedRangeEnd !== rangeEnd) {
log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
rangeEnd = rangeStart + maxMediaDuration;
rangeEnd = cappedRangeEnd;
}
this.deps.showOsdNotification('Updating card from clipboard...');
@@ -437,9 +439,7 @@ export class CardCreationService {
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
endTime = startTime + maxMediaDuration;
}
endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
this.deps.showOsdNotification('Marking card as audio card...');
await this.deps.withUpdateProgress('Marking audio card', async () => {
@@ -536,6 +536,7 @@ export class CardCreationService {
endTime,
animatedLeadInSeconds,
exactReviewedRange,
timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined,
);
const imageField = this.deps.getConfig().fields?.image;
@@ -599,11 +600,12 @@ export class CardCreationService {
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
if (cappedEndTime !== endTime) {
log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
endTime = startTime + maxMediaDuration;
endTime = cappedEndTime;
}
try {
@@ -796,6 +798,9 @@ export class CardCreationService {
generateImage,
volumeScale,
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
...(timingDecision.action === 'confirm' && timingDecision.screenshotTime !== undefined
? { screenshotTime: timingDecision.screenshotTime }
: {}),
});
await this.deps.showNotification(noteId, label, 'media queued');
return true;
@@ -840,6 +845,7 @@ export class CardCreationService {
endTime,
0,
exactReviewedRange,
timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined,
);
const imageField = config.fields?.image;
@@ -922,15 +928,16 @@ export class CardCreationService {
endTime: number,
animatedLeadInSeconds = 0,
exactReviewedRange = false,
screenshotTime?: number,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
return null;
}
const timestamp = exactReviewedRange
? startTime + (endTime - startTime) / 2
: mpvClient.currentTimePos || 0;
const timestamp =
screenshotTime ??
(exactReviewedRange ? startTime + (endTime - startTime) / 2 : mpvClient.currentTimePos || 0);
if (this.deps.getConfig().media?.imageType === 'avif') {
let imageStart = startTime;
+10
View File
@@ -0,0 +1,10 @@
/** Zero or a negative cap leaves the requested end time unchanged. */
export function clampMediaEndTime(
startTime: number,
endTime: number,
maxMediaDuration: number,
): number {
return maxMediaDuration > 0 && endTime - startTime > maxMediaDuration
? startTime + maxMediaDuration
: endTime;
}
@@ -729,6 +729,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi
startTime: 2,
endTime: 7,
text: 'previous-line current-line next-line',
screenshotTime: 8,
});
harness.deps.generateAudio = async (context) => {
audioContexts.push(context);
@@ -745,6 +746,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi
assert.equal(audioContexts[0]?.startTime, 2);
assert.equal(audioContexts[0]?.endTime, 7);
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
assert.equal(audioContexts[0]?.screenshotTime, 8);
});
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
@@ -253,6 +253,9 @@ export class NoteUpdateWorkflow {
startTime: timingDecision.startTime,
endTime: timingDecision.endTime,
mediaPaddingSeconds: 0,
...(timingDecision.screenshotTime !== undefined
? { screenshotTime: timingDecision.screenshotTime }
: {}),
};
} else if (timingDecision.action === 'skip-media') {
skipMedia = true;
@@ -51,6 +51,41 @@ function createDeps(
return deps;
}
test('queued media keeps a chosen screenshot separate from the reviewed audio range', async () => {
const screenshots: number[] = [];
const audioRanges: number[][] = [];
const deps = createDeps();
deps.client.notesInfo = async () => [{ noteId: 42, fields: { Picture: { value: '' } } }];
deps.mediaGenerator.generateScreenshot = async (_media, time) => {
screenshots.push(time);
return Buffer.from('image');
};
deps.mediaGenerator.generateAudio = async (_media, start, end, padding) => {
audioRanges.push([start, end, padding ?? -1]);
return Buffer.from('audio');
};
const queue = new PendingYoutubeMediaQueue(deps);
assert.equal(
await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'test',
context: {
source: 'overlay',
text: '字幕',
startTime: 1,
endTime: 2,
mediaPaddingSeconds: 0,
screenshotTime: 3.125,
},
}),
true,
);
await queue.handleReady('https://youtu.be/abc123', '/cache/video.mkv');
assert.deepEqual(screenshots, [3.125]);
assert.deepEqual(audioRanges, [[1, 2, 0]]);
});
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
const deps = createDeps({
getCachedMediaPath: async () => {
@@ -147,6 +147,9 @@ export class PendingYoutubeMediaQueue {
generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config),
volumeScale,
...(job.context?.screenshotTime !== undefined
? { screenshotTime: job.context.screenshotTime }
: {}),
...(job.context?.mediaPaddingSeconds !== undefined
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
: {}),
@@ -320,6 +323,7 @@ export class PendingYoutubeMediaQueue {
job.endTime,
animatedLeadInSeconds,
job.mediaPaddingSeconds,
job.screenshotTime,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -381,6 +385,7 @@ export class PendingYoutubeMediaQueue {
endTime: number,
animatedLeadInSeconds = 0,
mediaPaddingSeconds?: number,
screenshotTime?: number,
): Promise<Buffer | null> {
const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') {
@@ -399,7 +404,7 @@ export class PendingYoutubeMediaQueue {
);
}
const timestamp = startTime + (endTime - startTime) / 2;
const timestamp = screenshotTime ?? startTime + (endTime - startTime) / 2;
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: config.media?.imageFormat as 'jpg' | 'png' | 'webp',
quality: config.media?.imageQuality,
@@ -11,6 +11,7 @@ export interface PendingYoutubeMediaUpdate {
generateImage: boolean;
volumeScale?: number;
mediaPaddingSeconds?: number;
screenshotTime?: number;
}
function trimToNonEmptyString(value: unknown): string | null {
+17
View File
@@ -19,6 +19,23 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
);
});
test('source and coverage scripts discover the same maintained source lane', () => {
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
/run-coverage-lane\.ts\s+([^\s]+)/,
)?.[1];
assert.equal(sourceLane, 'bun-src-full');
assert.equal(coverageLane, sourceLane);
});
test('environment suite owns launcher smoke execution', () => {
assert.match(
packageJson.scripts['test:env'] ?? '',
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
);
});
test('ci delegates its gate instead of duplicating quality steps', () => {
assert.match(
ciWorkflow,
+23
View File
@@ -2819,6 +2819,29 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou
);
});
test('warns and falls back when isSenren.enabled is not boolean', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isSenren": { "enabled": "true" }
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(
service.getConfig().ankiConnect.isSenren.enabled,
DEFAULT_CONFIG.ankiConnect.isSenren.enabled,
);
assert.ok(
service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
);
});
test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir();
fs.writeFileSync(
+2
View File
@@ -43,6 +43,7 @@ const {
anime,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -78,6 +79,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
anime,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -7,6 +7,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'anime'
| 'jimaku'
| 'tsukihime'
| 'tmdb'
| 'anilist'
| 'mpv'
| 'yomitan'
@@ -30,6 +31,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fields: {
word: 'Expression',
audio: 'ExpressionAudio',
wordAudio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
miscInfo: 'MiscInfo',
@@ -123,6 +125,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10,
},
tmdb: {
apiKey: '',
apiKeyCommand: '',
},
mpv: {
executablePath: '',
launchMode: 'normal',
+22 -1
View File
@@ -82,6 +82,13 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.fields.audio,
description: 'Card field that receives generated sentence audio.',
},
{
path: 'ankiConnect.fields.wordAudio',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.wordAudio,
description:
'Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.',
},
{
path: 'ankiConnect.fields.image',
kind: 'string',
@@ -288,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.media.maxMediaDuration',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
description: 'Maximum allowed media clip duration in seconds.',
description: 'Maximum allowed media clip duration in seconds. 0 disables the cap.',
},
{
path: 'ankiConnect.knownWords.matchMode',
@@ -472,6 +479,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum TsukiHime search results returned.',
},
{
path: 'tmdb.apiKey',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKey,
description:
'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).',
},
{
path: 'tmdb.apiKeyCommand',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKeyCommand,
description:
'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
+9 -1
View File
@@ -144,7 +144,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'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.',
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/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.',
],
@@ -175,6 +175,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'tsukihime',
},
{
title: 'TMDB',
description: [
'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.',
],
notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'],
key: 'tmdb',
},
{
title: 'YouTube Playback Settings',
description: [
+66
View File
@@ -0,0 +1,66 @@
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'anime.autoOpenJimaku',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.wordAudio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
export function getConfigHotReloadField(path: string): string | null {
for (const root of HOT_RELOAD_ROOTS) {
if (pathStartsWith(path, root)) {
return root;
}
}
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
if (pathStartsWith(path, hotPath)) {
return hotPath;
}
}
// These consumers read the current config when the next operation starts.
if (
['jimaku', 'tmdb', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
pathStartsWith(path, root),
)
) {
return path;
}
return null;
}
+117
View File
@@ -32,6 +32,101 @@ test('media timing review is disabled by default and accepts a boolean override'
assert.deepEqual(enabledContext.warnings, []);
});
test('invalid direct and field-grouping Anki values warn and keep defaults', () => {
const { context, warnings } = makeContext({
enabled: 'true',
url: 8765,
pollingRate: '3000',
deck: ['Mining'],
isKiku: {
enabled: 1,
fieldGrouping: 'sometimes',
deleteDuplicateInAuto: 'false',
},
isSenren: {
enabled: 'true',
fieldGrouping: false,
deleteDuplicateInAuto: 0,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.enabled, DEFAULT_CONFIG.ankiConnect.enabled);
assert.equal(context.resolved.ankiConnect.url, DEFAULT_CONFIG.ankiConnect.url);
assert.equal(context.resolved.ankiConnect.pollingRate, DEFAULT_CONFIG.ankiConnect.pollingRate);
assert.equal(context.resolved.ankiConnect.deck, DEFAULT_CONFIG.ankiConnect.deck);
assert.deepEqual(context.resolved.ankiConnect.isKiku, DEFAULT_CONFIG.ankiConnect.isKiku);
assert.deepEqual(context.resolved.ankiConnect.isSenren, DEFAULT_CONFIG.ankiConnect.isSenren);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.enabled',
'ankiConnect.url',
'ankiConnect.pollingRate',
'ankiConnect.deck',
'ankiConnect.isKiku.enabled',
'ankiConnect.isKiku.deleteDuplicateInAuto',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.enabled',
'ankiConnect.isSenren.deleteDuplicateInAuto',
'ankiConnect.isSenren.fieldGrouping',
],
);
});
test('accepts valid direct and field-grouping Anki values', () => {
const { context, warnings } = makeContext({
enabled: false,
url: 'http://127.0.0.1:9876',
pollingRate: 750,
deck: 'Mining',
isKiku: {
enabled: true,
fieldGrouping: 'manual',
deleteDuplicateInAuto: false,
},
isSenren: {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: false,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.enabled, false);
assert.equal(context.resolved.ankiConnect.url, 'http://127.0.0.1:9876');
assert.equal(context.resolved.ankiConnect.pollingRate, 750);
assert.equal(context.resolved.ankiConnect.deck, 'Mining');
assert.deepEqual(context.resolved.ankiConnect.isKiku, {
enabled: true,
fieldGrouping: 'manual',
deleteDuplicateInAuto: false,
});
assert.deepEqual(context.resolved.ankiConnect.isSenren, {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: false,
});
assert.deepEqual(warnings, []);
});
test('ignores unknown Anki keys without warning or admitting them to resolved config', () => {
const { context, warnings } = makeContext({
futureOption: { enabled: true },
isKiku: { futureGroupingOption: 'future' },
isSenren: { futureGroupingOption: 'future' },
});
applyAnkiConnectResolution(context);
assert.equal(Object.hasOwn(context.resolved.ankiConnect, 'futureOption'), false);
assert.equal(Object.hasOwn(context.resolved.ankiConnect.isKiku, 'futureGroupingOption'), false);
assert.equal(Object.hasOwn(context.resolved.ankiConnect.isSenren, 'futureGroupingOption'), false);
assert.deepEqual(warnings, []);
});
test('modern media duration accepts zero as the disabled cap sentinel', () => {
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
applyAnkiConnectResolution(disabledCap.context);
@@ -290,6 +385,28 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
);
});
test('word audio mapping defaults and validates independently of sentence audio', () => {
for (const wordAudio of [undefined, 'Pronunciation', 7]) {
const { context, warnings } = makeContext({
fields: {
audio: 'SentenceAudio',
...(wordAudio !== undefined ? { wordAudio } : {}),
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.audio, 'SentenceAudio');
assert.equal(
context.resolved.ankiConnect.fields.wordAudio,
typeof wordAudio === 'string' ? wordAudio : DEFAULT_CONFIG.ankiConnect.fields.wordAudio,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
typeof wordAudio === 'number' ? ['ankiConnect.fields.wordAudio'] : [],
);
}
});
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
const { context, warnings } = makeContext({
fields: { word: 7 },
+3 -3
View File
@@ -19,11 +19,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const media = isObject(ankiConnect.media) ? ankiConnect.media : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
initializeAnkiConnectResolution(context, ankiConnect);
initializeAnkiConnectResolution(context);
applyAnkiModernResolution(context, ankiConnect, behavior, media);
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
applyAnkiSenrenResolution(context);
applyAnkiKikuResolution(context, ankiConnect);
applyAnkiSenrenResolution(context, ankiConnect);
applyAnkiLapisKikuResolution(context, ankiConnect);
}
+58
View File
@@ -0,0 +1,58 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean, asString } from '../shared';
import { applyModernValue, asPositiveNumber } from './modern-value';
export function applyAnkiBaseResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
applyModernValue(
context,
ankiConnect,
'enabled',
'ankiConnect.enabled',
asBoolean,
DEFAULT_CONFIG.ankiConnect.enabled,
(value) => {
context.resolved.ankiConnect.enabled = value;
},
'Expected boolean.',
);
applyModernValue(
context,
ankiConnect,
'url',
'ankiConnect.url',
asString,
DEFAULT_CONFIG.ankiConnect.url,
(value) => {
context.resolved.ankiConnect.url = value;
},
'Expected string.',
);
applyModernValue(
context,
ankiConnect,
'pollingRate',
'ankiConnect.pollingRate',
asPositiveNumber,
DEFAULT_CONFIG.ankiConnect.pollingRate,
(value) => {
context.resolved.ankiConnect.pollingRate = value;
},
'Expected positive number.',
);
applyModernValue(
context,
ankiConnect,
'deck',
'ankiConnect.deck',
asString,
DEFAULT_CONFIG.ankiConnect.deck,
(value) => {
context.resolved.ankiConnect.deck = value;
},
'Expected string.',
);
}
@@ -0,0 +1,53 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean, isObject } from '../shared';
import { applyModernValue } from './modern-value';
type FieldGroupingConfigKey = 'isKiku' | 'isSenren';
export function applyFieldGroupingConfigResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
key: FieldGroupingConfigKey,
): void {
const source = ankiConnect[key];
if (!isObject(source)) {
if (source !== undefined) {
context.warn(
`ankiConnect.${key}`,
source,
DEFAULT_CONFIG.ankiConnect[key],
'Expected object.',
);
}
return;
}
for (const booleanKey of ['enabled', 'deleteDuplicateInAuto'] as const) {
applyModernValue(
context,
source,
booleanKey,
`ankiConnect.${key}.${booleanKey}`,
asBoolean,
DEFAULT_CONFIG.ankiConnect[key][booleanKey],
(value) => {
context.resolved.ankiConnect[key][booleanKey] = value;
},
'Expected boolean.',
);
}
applyModernValue(
context,
source,
'fieldGrouping',
`ankiConnect.${key}.fieldGrouping`,
(value) => (value === 'auto' || value === 'manual' || value === 'disabled' ? value : undefined),
DEFAULT_CONFIG.ankiConnect[key].fieldGrouping,
(value) => {
context.resolved.ankiConnect[key].fieldGrouping = value;
},
'Expected auto, manual, or disabled.',
);
}
+1 -54
View File
@@ -1,55 +1,8 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
const LEGACY_KEYS = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
'miscInfoField',
'miscInfoPattern',
'generateAudio',
'generateImage',
'imageType',
'imageFormat',
'imageQuality',
'imageMaxWidth',
'imageMaxHeight',
'animatedFps',
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
'overwriteAudio',
'overwriteImage',
'mediaInsertMode',
'highlightWord',
'notificationType',
'autoUpdateNewCards',
]);
export function initializeAnkiConnectResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ankiConnect;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)),
);
export function initializeAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect = {
...context.resolved.ankiConnect,
...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
: {}),
fields: {
...context.resolved.ankiConnect.fields,
},
@@ -73,15 +26,9 @@ export function initializeAnkiConnectResolution(
},
isKiku: {
...context.resolved.ankiConnect.isKiku,
...(isObject(ankiConnect.isKiku)
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
isSenren: {
...context.resolved.ankiConnect.isSenren,
...(isObject(ankiConnect.isSenren)
? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren'])
: {}),
},
lapisKiku: {
...context.resolved.ankiConnect.lapisKiku,
+6 -16
View File
@@ -1,19 +1,9 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { applyFieldGroupingConfigResolution } from './field-grouping-config';
export function applyAnkiKikuResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isKiku.fieldGrouping',
context.resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
export function applyAnkiKikuResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
applyFieldGroupingConfigResolution(context, ankiConnect, 'isKiku');
}
@@ -7,7 +7,15 @@ export function applyModernFieldsResolution(
context: ResolveContext,
fields: Record<string, unknown>,
): void {
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
for (const key of [
'word',
'audio',
'wordAudio',
'image',
'sentence',
'miscInfo',
'translation',
] as const) {
applyModernValue(
context,
fields,
@@ -1,6 +1,7 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
import { applyAiResolution } from './ai';
import { applyAnkiBaseResolution } from './base';
import { applyLapisResolution } from './lapis';
import { applyModernBehaviorResolution } from './modern-behavior';
import { applyModernFieldsResolution } from './modern-fields';
@@ -18,6 +19,7 @@ export function applyAnkiModernResolution(
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
applyAnkiBaseResolution(context, ankiConnect);
applyModernFieldsResolution(context, fields);
applyModernMediaResolution(context, media);
applyModernBehaviorResolution(context, behavior);
+6 -16
View File
@@ -1,21 +1,11 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { applyFieldGroupingConfigResolution } from './field-grouping-config';
export function applyAnkiSenrenResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isSenren.fieldGrouping',
context.resolved.ankiConnect.isSenren.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isSenren.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping;
}
export function applyAnkiSenrenResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
applyFieldGroupingConfigResolution(context, ankiConnect, 'isSenren');
// Kiku and Senren field grouping write incompatible markup into the same note
// fields, so only one may be active; Kiku wins to preserve pre-existing setups.
+13
View File
@@ -59,6 +59,19 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('ai', src.ai, resolved.ai, 'Expected object.');
}
if (isObject(src.tmdb)) {
for (const key of ['apiKey', 'apiKeyCommand'] as const) {
const value = asString(src.tmdb[key]);
if (value !== undefined) {
resolved.tmdb[key] = value;
} else if (src.tmdb[key] !== undefined) {
warn(`tmdb.${key}`, src.tmdb[key], resolved.tmdb[key], 'Expected string.');
}
}
} else if (src.tmdb !== undefined) {
warn('tmdb', src.tmdb, resolved.tmdb, 'Expected object.');
}
if (isObject(src.anilist)) {
const enabled = asBoolean(src.anilist.enabled);
if (enabled !== undefined) {
+4
View File
@@ -290,6 +290,9 @@ test('settings registry routes playback-related integrations into integrations',
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
assert.equal(field('tmdb.apiKey').category, 'integrations');
assert.equal(field('tmdb.apiKey').section, 'TMDB');
assert.equal(field('tmdb.apiKey').secret, true);
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -379,6 +382,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.wordAudio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
+12 -52
View File
@@ -1,9 +1,9 @@
import { getConfigHotReloadField } from '../hot-reload';
import type { ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsCategory,
ConfigSettingsControl,
ConfigSettingsField,
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
@@ -93,7 +93,12 @@ const JSON_OBJECT_FIELDS = new Set([
'subtitleSidebar.css',
]);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set([
'ai.apiKey',
'jimaku.apiKey',
'tmdb.apiKey',
'anilist.accessToken',
]);
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
@@ -136,6 +141,7 @@ const SECTION_ORDER = new Map<string, number>(
'AnkiConnect Proxy',
'Aniyomi',
'Jimaku',
'TMDB',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
@@ -329,6 +335,7 @@ function humanizePath(path: string): string {
.replace(/\bmpv\b/i, 'mpv')
.replace(/\byomitan\b/i, 'Yomitan')
.replace(/\bjimaku\b/i, 'Jimaku')
.replace(/\btmdb\b/i, 'TMDB')
.replace(/\banilist\b/i, 'AniList')
.replace(/\banki\b/i, 'Anki');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -444,7 +451,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.') || path.startsWith('tmdb.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('anime.')) {
@@ -515,6 +522,7 @@ function topSection(path: string): string {
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
tsukihime: 'TsukiHime',
tmdb: 'TMDB',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
@@ -698,54 +706,6 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
return a.configPath.localeCompare(b.configPath);
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
pathStartsWith(path, 'shortcuts') ||
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
path === 'ankiConnect.deck' ||
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.media.normalizeAudio' ||
path === 'ankiConnect.media.mirrorMpvVolume' ||
path === 'ankiConnect.media.reviewTiming' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.nPlusOne.enabled' ||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
path === 'ankiConnect.fields.word' ||
path === 'ankiConnect.fields.audio' ||
path === 'ankiConnect.fields.image' ||
path === 'ankiConnect.fields.sentence' ||
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.isSenren.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'logging.rotation' ||
pathStartsWith(path, 'logging.files') ||
pathStartsWith(path, 'notifications') ||
path === 'anime.autoOpenJimaku' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync') ||
pathStartsWith(path, 'subtitleGeneration')
) {
return 'hot-reload';
}
return 'restart';
}
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
@@ -764,7 +724,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
? { enumValues: option.settingsEnumValues ?? option.enumValues }
: {}),
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: restartBehaviorForPath(leaf.path),
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more