diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5416cdc4..29af305b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,16 @@
# Changelog
+## Unreleased
+
+### Fixed
+
+- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
+- Anime Browser Playback Session: Launching a video from the anime browser now starts a regular SubMiner session (tray icon plus the on-demand overlay runtime), and in `subminer anime` standalone mode, closing the browser window during playback no longer quits the app and kills the stream — the window can be reopened from the tray while mpv keeps playing.
+
## v0.19.0 (2026-07-29)
### Added
+
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
@@ -10,9 +18,11 @@
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
### Changed
+
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
### Fixed
+
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
@@ -28,6 +38,7 @@
Internal changes
### Internal
+
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
- Consolidated renderer modal state handling into a descriptor registry.
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
@@ -39,10 +50,12 @@
## v0.18.0 (2026-07-10)
### Added
+
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
### Changed
+
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
@@ -50,6 +63,7 @@
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
### Fixed
+
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
@@ -63,6 +77,7 @@
Internal changes
### Internal
+
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
@@ -70,15 +85,18 @@
## v0.17.2 (2026-06-28)
### Fixed
+
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
## v0.17.1 (2026-06-27)
### Added
+
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
### Fixed
+
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
@@ -159,6 +177,7 @@
Internal changes
### Internal
+
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
@@ -168,9 +187,11 @@
## v0.15.2 (2026-06-02)
### Changed
+
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
### Fixed
+
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
diff --git a/changes/anime-browser.md b/changes/anime-browser.md
new file mode 100644
index 00000000..b360a9dd
--- /dev/null
+++ b/changes/anime-browser.md
@@ -0,0 +1,15 @@
+type: added
+area: anime
+
+- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
+- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
+- Anime extensions are read from `/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
+- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per source.
+- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason.
+- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
+- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
+- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers — a fast source is on screen while a slow one is still resolving, with per-source progress in the status bar. Results are tagged with the source they came from, and a source that fails is named in the status bar instead of blanking the grid.
+- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
+- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
+- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
+- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
diff --git a/config.example.jsonc b/config.example.jsonc
index eb9c8aae..f1971c4b 100644
--- a/config.example.jsonc
+++ b/config.example.jsonc
@@ -607,10 +607,22 @@
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting.
"lapisKiku": {
- "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag. Values: word-and-sentence | click | sentence | audio | none
+ "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options.
+ // ==========================================
+ // Anime Browser
+ // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+ // add a repository index URL here (or drop .apk files in the extensions directory) to have any.
+ // Hot-reload: anime changes apply the next time the anime browser opens.
+ // ==========================================
+ "anime": {
+ "extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses /anime-extensions.
+ "repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
+ "preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
+ }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts
index 1171bea8..987433ac 100644
--- a/docs-site/.vitepress/config.ts
+++ b/docs-site/.vitepress/config.ts
@@ -327,6 +327,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Anki', link: '/anki-integration' },
{ text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' },
+ { text: 'Anime Browser', link: '/anime-browser' },
{ text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' },
diff --git a/docs-site/.vitepress/theme/mermaid-modal.css b/docs-site/.vitepress/theme/mermaid-modal.css
index d54e8d19..225dda05 100644
--- a/docs-site/.vitepress/theme/mermaid-modal.css
+++ b/docs-site/.vitepress/theme/mermaid-modal.css
@@ -45,7 +45,9 @@
border: 1px solid var(--vp-c-border);
border-radius: 0;
background: var(--vp-c-bg);
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 24px 64px rgba(0, 0, 0, 0.2);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ 0 24px 64px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
@@ -62,7 +64,9 @@
font-family: var(--tui-font-mono);
font-size: 12px;
cursor: pointer;
- transition: border-color 180ms ease, color 180ms ease;
+ transition:
+ border-color 180ms ease,
+ color 180ms ease;
}
.mermaid-modal__close:hover {
diff --git a/docs-site/.vitepress/theme/status-line.test.ts b/docs-site/.vitepress/theme/status-line.test.ts
index 61317fd4..ab095282 100644
--- a/docs-site/.vitepress/theme/status-line.test.ts
+++ b/docs-site/.vitepress/theme/status-line.test.ts
@@ -10,7 +10,5 @@ test('status line file path formats version archive home without trailing slash'
});
test('status line file path keeps normal docs routes as markdown files', () => {
- expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
- 'v/0.12.0/configuration.md',
- );
+ expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe('v/0.12.0/configuration.md');
});
diff --git a/docs-site/.vitepress/theme/tui-theme.css b/docs-site/.vitepress/theme/tui-theme.css
index e9026ff3..f0dcf96c 100644
--- a/docs-site/.vitepress/theme/tui-theme.css
+++ b/docs-site/.vitepress/theme/tui-theme.css
@@ -22,17 +22,8 @@
:root {
--tui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--tui-font-body:
- 'Manrope Default',
- 'M PLUS 1',
- 'Manrope',
- 'Noto Sans CJK JP',
- 'Noto Sans JP',
- 'Hiragino Kaku Gothic ProN',
- 'Meiryo',
- 'Yu Gothic',
- 'Hiragino Sans',
- system-ui,
- sans-serif;
+ 'Manrope Default', 'M PLUS 1', 'Manrope', 'Noto Sans CJK JP', 'Noto Sans JP',
+ 'Hiragino Kaku Gothic ProN', 'Meiryo', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif;
--tui-transition: 180ms ease;
/* Theme-specific values — overridden in .dark below */
@@ -96,8 +87,11 @@ button,
.VPFeature,
.VPNavBarMenuLink,
.VPSidebarItem .text {
- transition: color var(--tui-transition), background var(--tui-transition),
- border-color var(--tui-transition), opacity var(--tui-transition);
+ transition:
+ color var(--tui-transition),
+ background var(--tui-transition),
+ border-color var(--tui-transition),
+ opacity var(--tui-transition);
}
/* === Nav bar === */
@@ -218,8 +212,7 @@ button,
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-brand-1);
- font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP',
- monospace;
+ font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace;
font-variant-ligatures: none;
}
@@ -228,8 +221,7 @@ button,
border-radius: 0;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt) !important;
- font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP',
- monospace;
+ font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace;
font-variant-ligatures: none;
}
@@ -273,7 +265,9 @@ button,
.vp-doc a {
text-decoration: none;
border-bottom: 1px solid var(--tui-link-underline);
- transition: border-color var(--tui-transition), color var(--tui-transition);
+ transition:
+ border-color var(--tui-transition),
+ color var(--tui-transition);
}
.vp-doc a:hover {
@@ -312,20 +306,45 @@ button,
border-left-width: 1px;
}
-.vp-doc .custom-block.tip { border-color: var(--vp-c-brand-1); }
-.vp-doc .custom-block.tip::before { content: '-- tip'; color: var(--vp-c-brand-1); }
+.vp-doc .custom-block.tip {
+ border-color: var(--vp-c-brand-1);
+}
+.vp-doc .custom-block.tip::before {
+ content: '-- tip';
+ color: var(--vp-c-brand-1);
+}
-.vp-doc .custom-block.info { border-color: var(--vp-c-brand-2); }
-.vp-doc .custom-block.info::before { content: '-- info'; color: var(--vp-c-brand-2); }
+.vp-doc .custom-block.info {
+ border-color: var(--vp-c-brand-2);
+}
+.vp-doc .custom-block.info::before {
+ content: '-- info';
+ color: var(--vp-c-brand-2);
+}
-.vp-doc .custom-block.warning { border-color: var(--vp-c-warning-1); }
-.vp-doc .custom-block.warning::before { content: '-- warning'; color: var(--vp-c-warning-1); }
+.vp-doc .custom-block.warning {
+ border-color: var(--vp-c-warning-1);
+}
+.vp-doc .custom-block.warning::before {
+ content: '-- warning';
+ color: var(--vp-c-warning-1);
+}
-.vp-doc .custom-block.danger { border-color: var(--vp-c-danger-1); }
-.vp-doc .custom-block.danger::before { content: '-- danger'; color: var(--vp-c-danger-1); }
+.vp-doc .custom-block.danger {
+ border-color: var(--vp-c-danger-1);
+}
+.vp-doc .custom-block.danger::before {
+ content: '-- danger';
+ color: var(--vp-c-danger-1);
+}
-.vp-doc .custom-block.details { border-color: var(--vp-c-divider); }
-.vp-doc .custom-block.details::before { content: '-- details'; color: var(--vp-c-text-2); }
+.vp-doc .custom-block.details {
+ border-color: var(--vp-c-divider);
+}
+.vp-doc .custom-block.details::before {
+ content: '-- details';
+ color: var(--vp-c-text-2);
+}
.vp-doc .custom-block .custom-block-title {
font-family: var(--tui-font-mono);
@@ -413,11 +432,15 @@ button,
}
@keyframes tui-blink {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0; }
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
}
-
/* === Statusline === */
.tui-statusline {
position: fixed;
@@ -457,7 +480,7 @@ button,
margin-left: -12px;
}
-.tui-statusline__mode[data-mode="HOME"] {
+.tui-statusline__mode[data-mode='HOME'] {
background: var(--vp-c-brand-2);
}
@@ -561,7 +584,9 @@ body {
.VPFeatures .VPFeature {
border-radius: 8px !important;
border: 1px solid var(--vp-c-divider) !important;
- transition: border-color var(--tui-transition), background var(--tui-transition),
+ transition:
+ border-color var(--tui-transition),
+ background var(--tui-transition),
transform var(--tui-transition);
position: relative;
overflow: hidden;
@@ -672,11 +697,7 @@ body {
transform: translateX(-50%);
width: 600px;
height: 400px;
- background: radial-gradient(
- ellipse at center,
- var(--tui-hero-glow) 0%,
- transparent 70%
- );
+ background: radial-gradient(ellipse at center, var(--tui-hero-glow) 0%, transparent 70%);
pointer-events: none;
z-index: -1;
}
diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md
new file mode 100644
index 00000000..fbffd5fd
--- /dev/null
+++ b/docs-site/anime-browser.md
@@ -0,0 +1,182 @@
+# Anime Browser
+
+Search anime sources, pick an episode, and play it in mpv with SubMiner's overlay
+and mining tools attached — the same way a local file or a Jellyfin stream works.
+
+Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
+**Browse Anime** in the tray menu. The window stays open while you watch, so you
+can queue the next episode without reopening it.
+
+While the window is open, SubMiner shows a tray icon and — on macOS — appears
+in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
+flip between the browser and mpv. SubMiner normally hides itself from the Dock
+because the subtitle overlay needs that to float above fullscreen video; it
+hides again when the window closes during playback.
+
+Launching an episode starts a full SubMiner playback session, the same as
+playing a local file: the overlay and mining tools attach, and the tray icon
+stays available. In standalone `subminer anime` mode, closing the window while
+a video is playing leaves playback running — reopen the browser from the tray
+(**Browse Anime**). The app only exits with the window when nothing is playing.
+
+## How it works
+
+SubMiner does not implement any anime source itself. It runs **Aniyomi extension
+APKs** through a bundled JVM sidecar ([M-Extension-Server][mes]), asks the
+selected extension to resolve an episode, and hands the resulting URL to mpv.
+
+```
+extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner overlay
+```
+
+Because the extension resolves the stream, whichever sources you install decide
+what is available. SubMiner only hosts them.
+
+## Installing extensions
+
+**SubMiner ships no extension repositories and bundles no sources.** There is no
+default repository, no suggested list, and no discovery. Until you add one, the
+browser has nothing to search — that is deliberate, and it is what keeps SubMiner
+a neutral host rather than a distributor.
+
+There are two ways to add extensions.
+
+### From a repository
+
+The window has three tabs — **Browse**, **Extensions**, and **Source settings** —
+and each one fills the window, so a long extension list is not squeezed in above
+the search results.
+
+Open the **Extensions** tab, paste a repository index URL, and choose
+**Add repository**. The URL must be `https` and point at a `.json` index file —
+`index.min.json` is the common Aniyomi name, but repositories are free to publish
+under another one (for example `video.min.json`). Anything else is rejected
+immediately rather than failing later. Everything before the file name is treated
+as the repository root, so `.apk` and icon URLs are resolved relative to it.
+
+Extensions your repositories offer but you do not have appear under
+**Available**, each with **Install**. Repositories are stored in config under
+`anime.repos`, so you can also manage them there and keep them in a dotfile.
+
+### Managing what is installed
+
+The Extensions tab opens with an **Installed** section listing everything in the
+extensions directory, with the sources each one provides and a **Remove**
+button. It is built from the directory rather than from a repository, so an
+extension you dropped in by hand — or one whose repository you have since
+removed — is still listed and still removable.
+
+**Update** appears next to an extension a configured repository still carries;
+it downloads the current version over the existing APK.
+
+### From a file
+
+Drop Aniyomi `.apk` files into the extensions directory, shown at the top of the
+Extensions tab. It defaults to `/anime-extensions` — on macOS,
+`~/Library/Application Support/SubMiner/anime-extensions` — and can be moved with
+`anime.extensionsDir`.
+
+A single APK may provide several sources; each appears separately in the
+**Source** picker. Extensions that fail to load are listed in the Installed
+section with the reason, so a broken APK is visible rather than silently
+missing.
+
+An extension that fails to load is skipped rather than blocking the others, so
+one bad APK will not hide the rest.
+
+## Searching every source at once
+
+With more than one source installed, the **Source** picker gains an
+**All sources** entry. Searching with it selected runs the query against every
+installed source at once, and each source's results appear the moment that
+source answers — a fast source is on screen while a slow one is still
+resolving. The status bar counts sources as they finish
+(`Searching… 3/5 sources · 42 results`).
+
+Each cover is labelled with the source it came from, and opening one always
+queries that source, whatever the picker says afterwards.
+
+A source that errors is named in the status bar and the rest still show their
+results; one extension that needs a login cannot blank the grid. If every
+source fails, the first error is shown in full.
+
+Typing a new search while one is still running simply starts over: results
+from the superseded search are discarded, even if its sources answer late.
+
+Source settings belong to a single extension, so the **Source settings** tab
+asks you to pick one while **All sources** is selected.
+
+## Settings
+
+| Key | Purpose |
+| ------------------------ | -------------------------------------------------------------------- |
+| `anime.repos` | Repository index URLs. Empty by default. |
+| `anime.extensionsDir` | Where APKs are read from. Empty uses `/anime-extensions`. |
+| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
+
+## Source settings
+
+Most extensions need configuration before they return anything — a server
+address and credentials, a preferred quality, a language filter. Open the
+**Source settings** tab to edit them. Changes save as you make them and
+persist across restarts in `/anime-source-preferences.json`.
+
+Each save is handed back to the extension, so it can react: the Jellyfin source
+logs in when the address and password land, then fills in its media-library
+picker. Password-like fields are masked. Because that file can hold
+credentials, it is written with owner-only permissions.
+
+## The bridge
+
+The first launch downloads a platform bundle (~130 MB) containing the server and
+a matching Java runtime, so no system JDK is required. It is verified against a
+pinned SHA-256 before running, unpacked into `/anime-bridge`, and
+reused after that. Progress appears in the banner at the top of the window.
+
+The bridge stays running while the window is open. Resolved video URLs point at
+its own loopback proxy so the extension's cookies and headers apply, which means
+those URLs stop working once it exits — the window keeps it alive for the whole
+session.
+
+Two known limits:
+
+- There is no Android WebView, so extensions that need one (typically for
+ Cloudflare challenges) will fail with an error from the source.
+- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64).
+ Other platforms are unsupported.
+
+## Playback
+
+Selecting an episode resolves the best available stream, applies the source's
+required headers as mpv `http-header-fields`, and loads it. The headers are
+readable back off mpv, so Anki card audio and screenshots fetch correctly too.
+
+### Japanese audio, and switching tracks
+
+Sources often return a dub and the original audio as two separate entries — or
+as two audio tracks of one stream — and the dub is frequently listed first.
+SubMiner always aims at the Japanese audio:
+
+- Entries labelled as a dub are skipped as long as another entry exists. This
+ outranks `anime.preferredQuality`: a 1080p dub is the wrong file, not a better
+ one. If every entry is a dub, it still plays.
+- mpv's `alang` is set to `ja,jpn,jp,japanese` before the file loads, so a
+ stream carrying several audio tracks starts on the Japanese one. With no
+ Japanese track, mpv falls back to the first one as usual.
+- Any audio or subtitle tracks the extension supplies separately are added to
+ mpv with `audio-add` / `sub-add`, tagged with their language, and the
+ Japanese one is selected.
+
+The primary subtitle slot is reserved for Japanese — it is what the overlay
+mines. A source that only carries, say, English subtitles does not get them
+promoted to primary; instead the track is added with a normalized language tag
+(`English` → `en`), and the regular [dual-subtitle settings](configuration.md)
+apply: with `secondarySub.autoLoadSecondarySub` enabled and the language listed
+in `secondarySub.secondarySubLanguages`, it is picked up as the secondary
+subtitle, exactly as it would be for a local file.
+
+Every track is added, including the ones that are not selected, so all of them
+appear in mpv's track menu and can be switched by hand while watching
+(`#` cycles audio, `j` cycles subtitles by default).
+
+[mes]: https://github.com/1Selxo/M-Extension-Server
diff --git a/docs-site/aniskip-integration.md b/docs-site/aniskip-integration.md
index fc9bc230..a8115b08 100644
--- a/docs-site/aniskip-integration.md
+++ b/docs-site/aniskip-integration.md
@@ -45,9 +45,9 @@ Results are cached per file for the app session; only definitive "no intro found
You can trigger AniSkip actions from mpv script-messages:
-| Command | Effect |
-| ------- | ------ |
-| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
+| Command | Effect |
+| ----------------------------------------- | ----------------------------------------------------------------------- |
+| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
These are handled by the SubMiner app over the IPC socket.
diff --git a/docs-site/changelog.md b/docs-site/changelog.md
index bfb75ba0..c5fcff5e 100644
--- a/docs-site/changelog.md
+++ b/docs-site/changelog.md
@@ -3,6 +3,7 @@
## v0.19.0 (2026-07-29)
**Added**
+
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
@@ -10,9 +11,11 @@
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
**Changed**
+
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
**Fixed**
+
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
@@ -28,6 +31,7 @@
Internal changes
**Internal**
+
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
- Consolidated renderer modal state handling into a descriptor registry.
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
@@ -44,10 +48,12 @@
v0.18.0 (2026-07-10)
**Added**
+
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
**Changed**
+
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
@@ -55,6 +61,7 @@
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
**Fixed**
+
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
@@ -68,6 +75,7 @@
Internal changes
**Internal**
+
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
@@ -80,15 +88,18 @@
v0.17.2 (2026-06-28)
**Fixed**
+
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
v0.17.1 (2026-06-27)
**Added**
+
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
**Fixed**
+
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
@@ -174,6 +185,7 @@
Internal changes
**Internal**
+
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
@@ -188,9 +200,11 @@
v0.15.2 (2026-06-02)
**Changed**
+
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
**Fixed**
+
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
diff --git a/docs-site/ipc-contracts.md b/docs-site/ipc-contracts.md
index 44ce3a62..9054dacc 100644
--- a/docs-site/ipc-contracts.md
+++ b/docs-site/ipc-contracts.md
@@ -38,7 +38,7 @@ flowchart TB
## Runtime Sockets
-The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
+The renderer↔main bridge above lives _inside_ the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
- **App control socket** (`/tmp/subminer-control--.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
@@ -69,15 +69,15 @@ How these sockets are established during launch is covered in [Playback Startup
## Core Surfaces
-| File | Role |
-| --- | --- |
-| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
-| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
-| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
-| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
-| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
-| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
-| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
+| File | Role |
+| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
+| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
+| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
+| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
+| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
+| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
+| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
## Contract Rules
diff --git a/docs-site/jimaku-integration.md b/docs-site/jimaku-integration.md
index de387548..a103cb57 100644
--- a/docs-site/jimaku-integration.md
+++ b/docs-site/jimaku-integration.md
@@ -23,12 +23,12 @@ If no files match the current episode filter, a "Show all files" button lets you
### Modal Keyboard Shortcuts
-| Key | Action |
-| --- | --- |
-| `Enter` (in text field) | Search |
-| `Enter` (in list) | Select entry / download file |
-| `Arrow Up` / `Arrow Down` | Navigate entries or files |
-| `Escape` | Close modal |
+| Key | Action |
+| ------------------------- | ---------------------------- |
+| `Enter` (in text field) | Search |
+| `Enter` (in list) | Select entry / download file |
+| `Arrow Up` / `Arrow Down` | Navigate entries or files |
+| `Escape` | Close modal |
## Configuration
@@ -41,26 +41,26 @@ Add a `jimaku` section to your `config.jsonc`:
"apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
- "maxEntryResults": 10
- }
+ "maxEntryResults": 10,
+ },
}
```
-| Option | Type | Default | Description |
-| --- | --- | --- | --- |
-| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
-| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
-| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
-| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
-| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
+| Option | Type | Default | Description |
+| --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
+| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
+| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
+| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
+| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
- "openJimaku": "Ctrl+Shift+J"
- }
+ "openJimaku": "Ctrl+Shift+J",
+ },
}
```
diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md
index 01112d8c..7be12870 100644
--- a/docs-site/mpv-plugin.md
+++ b/docs-site/mpv-plugin.md
@@ -31,20 +31,20 @@ input-ipc-server=\\.\pipe\subminer-socket
The plugin reads options from `script-opts` with the `subminer-` prefix (for example `--script-opts=subminer-backend=hyprland`). Managed launches inject these automatically from your SubMiner config; the shipped `subminer.conf` is intentionally empty so command-line opts always win.
-| Option | Default | Description |
-| -------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------- |
-| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
-| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
-| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
-| `texthooker_port` | `5174` | Texthooker server port |
-| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
-| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
-| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
-| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
-| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
-| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
-| `osd_messages` | `yes` | Show plugin OSD status messages |
-| `log_level` | `info` | Plugin log verbosity |
+| Option | Default | Description |
+| ---------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------- |
+| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
+| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
+| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
+| `texthooker_port` | `5174` | Texthooker server port |
+| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
+| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
+| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
+| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
+| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
+| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
+| `osd_messages` | `yes` | Show plugin OSD status messages |
+| `log_level` | `info` | Plugin log verbosity |
## Keybindings
diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc
index eb9c8aae..f1971c4b 100644
--- a/docs-site/public/config.example.jsonc
+++ b/docs-site/public/config.example.jsonc
@@ -607,10 +607,22 @@
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting.
"lapisKiku": {
- "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag. Values: word-and-sentence | click | sentence | audio | none
+ "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options.
+ // ==========================================
+ // Anime Browser
+ // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+ // add a repository index URL here (or drop .apk files in the extensions directory) to have any.
+ // Hot-reload: anime changes apply the next time the anime browser opens.
+ // ==========================================
+ "anime": {
+ "extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses /anime-extensions.
+ "repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
+ "preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
+ }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md
index 645cd144..0599313e 100644
--- a/docs-site/shortcuts.md
+++ b/docs-site/shortcuts.md
@@ -12,10 +12,10 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
## App-Wide Shortcuts
-| Shortcut | Action | Scope | Configurable |
-| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
-| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
-| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
+| Shortcut | Action | Scope | Configurable |
+| ------------- | ---------------------- | ---------------------------------------- | -------------------------------------- |
+| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
+| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
::: tip
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
@@ -75,21 +75,21 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts
-| Shortcut | Action | Config key |
-| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
-| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
-| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
-| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
-| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
-| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
-| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
-| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
-| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
-| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
-| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
+| Shortcut | Action | Config key |
+| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
+| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
+| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
+| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
+| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
+| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
+| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
+| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
+| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
+| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
-| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
-| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
+| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
+| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
`shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured.
diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md
index e5203085..d342e103 100644
--- a/docs-site/subtitle-sidebar.md
+++ b/docs-site/subtitle-sidebar.md
@@ -62,23 +62,23 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
-| `css` property | Default | Description |
-| ------------------------------------------- | --------------------------- | ---------------------------- |
-| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
-| `color` | `#cad3f5` | Default cue text color |
-| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
-| `font-size` | `16px` | Base cue font size |
-| `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
-| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
-| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
-| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
-| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color |
-| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
+| `css` property | Default | Description |
+| -------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- |
+| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
+| `color` | `#cad3f5` | Default cue text color |
+| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
+| `font-size` | `16px` | Base cue font size |
+| `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
+| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
+| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
+| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
+| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color |
+| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
## Keyboard Shortcut
-| Key | Action | Config key |
-| --- | ----------------------- | ------------------------------ |
-| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
+| Key | Action | Config key |
+| --- | ----------------------- | --------------------------- |
+| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference.
diff --git a/docs-site/usage.md b/docs-site/usage.md
index 3c58fc13..5787ec6f 100644
--- a/docs-site/usage.md
+++ b/docs-site/usage.md
@@ -70,6 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
subminer stats # Open the immersion stats dashboard
subminer doctor # Check dependencies, config, and the mpv socket
subminer settings # Open the SubMiner settings window
+subminer anime # Open the anime browser window
subminer app --setup # Re-open first-run setup
subminer -u # Check for updates
```
@@ -131,6 +132,7 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
+SubMiner.AppImage --anime # Open the anime browser window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
SubMiner.AppImage --start --dev # Enable app/dev mode
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md
index ed0b3e08..89dc1f0b 100644
--- a/docs-site/websocket-texthooker-api.md
+++ b/docs-site/websocket-texthooker-api.md
@@ -2,7 +2,7 @@
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
-A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
+A _texthooker_ is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows:
@@ -15,12 +15,12 @@ This page documents those integration points and shows how to build custom consu
## Quick Reference
-| Surface | Default | Purpose |
-| --- | --- | --- |
-| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream |
-| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata |
-| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
-| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
+| Surface | Default | Purpose |
+| --------------------- | --------------------------- | -------------------------------------------------- |
+| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream |
+| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata |
+| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
+| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
## Enable and Configure the Services
@@ -30,16 +30,16 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service
{
"websocket": {
"enabled": "auto",
- "port": 6677
+ "port": 6677,
},
"annotationWebsocket": {
"enabled": true,
- "port": 6678
+ "port": 6678,
},
"texthooker": {
"launchAtStartup": true,
- "openBrowser": false
- }
+ "openBrowser": false,
+ },
}
```
@@ -79,12 +79,12 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
#### Field reference
-| Field | Type | Notes |
-| --- | --- | --- |
-| `version` | number | Current websocket payload version. Today this is `1`. |
-| `text` | string | Raw subtitle text. |
+| Field | Type | Notes |
+| ---------- | ------ | ---------------------------------------------------------------------------------------------- |
+| `version` | number | Current websocket payload version. Today this is `1`. |
+| `text` | string | Raw subtitle text. |
| `sentence` | string | Plain subtitle text with line breaks represented as ` `. No annotation spans or attributes. |
-| `tokens` | array | Always empty on the basic subtitle websocket. |
+| `tokens` | array | Always empty on the basic subtitle websocket. |
### 2. Annotation WebSocket
@@ -127,22 +127,22 @@ In practice, if you are building a new client, prefer `annotationWebsocket` unle
Each annotation token may include:
-| Token field | Type | Notes |
-| --- | --- | --- |
-| `surface` | string | Display text for the token |
-| `reading` | string | Kana reading when available |
-| `headword` | string | Dictionary headword when available |
-| `startPos` / `endPos` | number | Character offsets in the subtitle text |
-| `partOfSpeech` | string | SubMiner token POS label |
-| `isMerged` | boolean | Whether this token represents merged content |
-| `isKnown` | boolean | Marked known by SubMiner's known-word logic |
-| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target |
-| `isNameMatch` | boolean | True for prioritized character-name matches |
-| `frequencyRank` | number | Frequency rank when available |
-| `jlptLevel` | string | JLPT level when available |
-| `className` | string | CSS-ready class list derived from token state |
-| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
-| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
+| Token field | Type | Notes |
+| --------------------- | ---------------- | ------------------------------------------------ |
+| `surface` | string | Display text for the token |
+| `reading` | string | Kana reading when available |
+| `headword` | string | Dictionary headword when available |
+| `startPos` / `endPos` | number | Character offsets in the subtitle text |
+| `partOfSpeech` | string | SubMiner token POS label |
+| `isMerged` | boolean | Whether this token represents merged content |
+| `isKnown` | boolean | Marked known by SubMiner's known-word logic |
+| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target |
+| `isNameMatch` | boolean | True for prioritized character-name matches |
+| `frequencyRank` | number | Frequency rank when available |
+| `jlptLevel` | string | JLPT level when available |
+| `className` | string | CSS-ready class list derived from token state |
+| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
+| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
### 3. HTML markup conventions
diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md
index cdc2ae0d..4611642d 100644
--- a/docs-site/youtube-integration.md
+++ b/docs-site/youtube-integration.md
@@ -110,8 +110,8 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma
}
```
-| Option | Type | Description |
-| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
+| Option | Type | Description |
+| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. |
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked.
@@ -130,11 +130,11 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma
}
```
-| Option | Type | Description |
-| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Option | Type | Description |
+| ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track for local/Jellyfin sidecar files. Default is empty (`[]`). Not used for YouTube. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary sidecar track for local files (default: `false`). Not used for YouTube. |
-| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
+| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md
index 8b01ff9a..ec1daf4d 100644
--- a/docs/architecture/2026-03-15-renderer-performance-design.md
+++ b/docs/architecture/2026-03-15-renderer-performance-design.md
@@ -64,15 +64,17 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:**
+
```typescript
interface SubtitleCue {
- startTime: number; // seconds
- endTime: number; // seconds
- text: string; // raw subtitle text
+ startTime: number; // seconds
+ endTime: number; // seconds
+ text: string; // raw subtitle text
}
```
**Supported formats:**
+
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
@@ -153,6 +155,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
### Dependency Analysis
All annotations either depend on MeCab POS data or benefit from running after it:
+
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
@@ -169,18 +172,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
// Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => {
- const isKnown = nPlusOneEnabled
- ? token.isKnown || computeIsKnown(token, deps)
- : false;
+ const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
// Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined;
- const jlptLevel = jlptEnabled
- ? computeJlptLevel(token, deps.getJlptLevel)
- : undefined;
+ const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
return { ...token, isKnown, frequencyRank, jlptLevel };
});
@@ -221,6 +220,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
+
1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -256,27 +256,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Combined Impact Summary
-| Scenario | Before | After | Improvement |
-|----------|--------|-------|-------------|
-| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
-| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
-| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
+| Scenario | Before | After | Improvement |
+| --------------------------------- | ---------- | ---------- | ----------- |
+| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
+| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
+| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
---
## Files Summary
### New Files
+
- `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts`
### Modified Files
+
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service)
### Test Files
+
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation)
diff --git a/docs/knowledge-base/catalog.md b/docs/knowledge-base/catalog.md
index d1235037..81eae9e4 100644
--- a/docs/knowledge-base/catalog.md
+++ b/docs/knowledge-base/catalog.md
@@ -7,21 +7,21 @@ Last verified: 2026-05-23
Owner: Kyle Yasuda
Read when: finding internal docs or checking verification status
-| Area | Path | Status | Last verified | Notes |
-| --- | --- | --- | --- | --- |
-| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
-| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
-| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
-| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
-| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
-| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
-| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
-| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
-| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
-| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
-| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
-| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
-| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
+| Area | Path | Status | Last verified | Notes |
+| ------------------------ | ----------------------------------------------- | ------ | ------------- | ------------------------------------------ |
+| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
+| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
+| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
+| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
+| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
+| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
+| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
+| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
+| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
+| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
+| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
+| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
+| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
## Update Rules
diff --git a/docs/knowledge-base/quality.md b/docs/knowledge-base/quality.md
index df7d4ffe..9fa5c1ab 100644
--- a/docs/knowledge-base/quality.md
+++ b/docs/knowledge-base/quality.md
@@ -11,27 +11,27 @@ Grades are directional, not ceremonial. The point is to keep gaps visible.
## Product / Runtime Domains
-| Area | Grade | Notes |
-| --- | --- | --- |
-| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
-| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
-| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
-| Overlay renderer | B | improved modularity; interaction complexity remains |
-| Config system | A- | clear defaults/definitions split and good validation surface |
-| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
-| Internal docs system | B | new structure in place; needs habitual maintenance |
-| Public docs site | B | strong user docs; must stay separate from internal KB |
+| Area | Grade | Notes |
+| --------------------------------------- | ----- | ------------------------------------------------------------------------ |
+| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
+| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
+| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
+| Overlay renderer | B | improved modularity; interaction complexity remains |
+| Config system | A- | clear defaults/definitions split and good validation surface |
+| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
+| Internal docs system | B | new structure in place; needs habitual maintenance |
+| Public docs site | B | strong user docs; must stay separate from internal KB |
## Architectural Layers
-| Layer | Grade | Notes |
-| --- | --- | --- |
-| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
-| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
-| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
-| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
-| `launcher/` | B | clear command boundaries |
-| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
+| Layer | Grade | Notes |
+| ------------------------------ | ----- | --------------------------------------------------------- |
+| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
+| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
+| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
+| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
+| `launcher/` | B | clear command boundaries |
+| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
## Current Gaps
diff --git a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md
index 7fb37fe4..cd5f9476 100644
--- a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md
+++ b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md
@@ -15,13 +15,16 @@
## File Structure
**Backend (`src/core/services/immersion-tracker/`):**
+
- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`.
- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred).
**Backend test fixtures:**
+
- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`.
**Frontend (`stats/src/`):**
+
- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field.
- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`).
- `components/trends/LibrarySummarySection.tsx` — **new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props.
@@ -29,6 +32,7 @@
- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision).
**Changelog:**
+
- `changes/stats-library-summary.md` — **new** changelog fragment.
---
@@ -36,6 +40,7 @@
## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field
**Files:**
+
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object**
@@ -82,6 +87,7 @@ git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field"
## Task 2: Backend — TDD the `buildLibrarySummary` helper
**Files:**
+
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -160,16 +166,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
- ).run(
- `${startedAtMs + activeMs}`,
- activeMs,
- activeMs,
- 10,
- tokens,
- cards,
- lookups,
- sessionId,
- );
+ ).run(`${startedAtMs + activeMs}`, activeMs, activeMs, 10, tokens, cards, lookups, sessionId);
}
for (const [day, active, tokens, cards] of [
@@ -289,8 +286,7 @@ function buildLibrarySummary(
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
- lookupsPerHundred:
- acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
+ lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
@@ -334,6 +330,7 @@ git commit -m "feat(stats): build per-title librarySummary from daily rollups an
## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests
**Files:**
+
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0**
@@ -402,16 +399,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
- ).run(
- `${startMs + 20 * 60_000}`,
- 20 * 60_000,
- 20 * 60_000,
- 5,
- 0,
- 0,
- 0,
- session.sessionId,
- );
+ ).run(`${startMs + 20 * 60_000}`, 20 * 60_000, 20 * 60_000, 5, 0, 0, 0, session.sessionId);
db.prepare(
`
@@ -464,6 +452,7 @@ git commit -m "test(stats): cover librarySummary null-lookups and empty-window c
## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers
**Files:**
+
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- Modify: `src/core/services/__tests__/stats-server.test.ts`
@@ -489,61 +478,61 @@ animePerDay: {
In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement:
```ts
- const animePerDay = {
- episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
- watchTime: buildPerAnimeFromDailyRollups(
- dailyRollups,
- titlesByVideoId,
- (rollup) => rollup.totalActiveMin,
- ),
- cards: buildPerAnimeFromDailyRollups(
- dailyRollups,
- titlesByVideoId,
- (rollup) => rollup.totalCards,
- ),
- words: buildPerAnimeFromDailyRollups(
- dailyRollups,
- titlesByVideoId,
- (rollup) => rollup.totalTokensSeen,
- ),
- };
+const animePerDay = {
+ episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
+ watchTime: buildPerAnimeFromDailyRollups(
+ dailyRollups,
+ titlesByVideoId,
+ (rollup) => rollup.totalActiveMin,
+ ),
+ cards: buildPerAnimeFromDailyRollups(
+ dailyRollups,
+ titlesByVideoId,
+ (rollup) => rollup.totalCards,
+ ),
+ words: buildPerAnimeFromDailyRollups(
+ dailyRollups,
+ titlesByVideoId,
+ (rollup) => rollup.totalTokensSeen,
+ ),
+};
- return {
- activity,
- progress: {
- watchTime: accumulatePoints(activity.watchTime),
- sessions: accumulatePoints(activity.sessions),
- words: accumulatePoints(activity.words),
- newWords: accumulatePoints(
- useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
- ),
- cards: accumulatePoints(activity.cards),
- episodes: accumulatePoints(
- useMonthlyBuckets
- ? buildEpisodesPerMonthFromRollups(monthlyRollups)
- : buildEpisodesPerDayFromDailyRollups(dailyRollups),
- ),
- lookups: accumulatePoints(
- useMonthlyBuckets
- ? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
- : buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
- ),
- },
- ratios: {
- lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
- },
- librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
- animeCumulative: {
- watchTime: buildCumulativePerAnime(animePerDay.watchTime),
- episodes: buildCumulativePerAnime(animePerDay.episodes),
- cards: buildCumulativePerAnime(animePerDay.cards),
- words: buildCumulativePerAnime(animePerDay.words),
- },
- patterns: {
- watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
- watchTimeByHour: buildWatchTimeByHour(sessions),
- },
- };
+return {
+ activity,
+ progress: {
+ watchTime: accumulatePoints(activity.watchTime),
+ sessions: accumulatePoints(activity.sessions),
+ words: accumulatePoints(activity.words),
+ newWords: accumulatePoints(
+ useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
+ ),
+ cards: accumulatePoints(activity.cards),
+ episodes: accumulatePoints(
+ useMonthlyBuckets
+ ? buildEpisodesPerMonthFromRollups(monthlyRollups)
+ : buildEpisodesPerDayFromDailyRollups(dailyRollups),
+ ),
+ lookups: accumulatePoints(
+ useMonthlyBuckets
+ ? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
+ : buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
+ ),
+ },
+ ratios: {
+ lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
+ },
+ librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
+ animeCumulative: {
+ watchTime: buildCumulativePerAnime(animePerDay.watchTime),
+ episodes: buildCumulativePerAnime(animePerDay.episodes),
+ cards: buildCumulativePerAnime(animePerDay.cards),
+ words: buildCumulativePerAnime(animePerDay.words),
+ },
+ patterns: {
+ watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
+ watchTimeByHour: buildWatchTimeByHour(sessions),
+ },
+};
```
- [ ] **Step 3: Delete now-unused helpers**
@@ -622,6 +611,7 @@ git commit -m "refactor(stats): drop animePerDay from trends response in favor o
## Task 5: Frontend — Update types and api-client test fixtures
**Files:**
+
- Modify: `stats/src/types/stats.ts`
- Modify: `stats/src/lib/api-client.test.ts`
@@ -710,6 +700,7 @@ git commit -m "refactor(stats): replace animePerDay type with librarySummary"
## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state
**Files:**
+
- Create: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Create the file with the empty state and props plumbing**
@@ -765,6 +756,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection`
**Files:**
+
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Replace the skeleton body with the leaderboard chart**
@@ -772,15 +764,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with:
```tsx
-import {
- Bar,
- BarChart,
- CartesianGrid,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from 'recharts';
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
@@ -821,9 +805,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
@@ -336,6 +340,7 @@
## Task 5: Episode detail — filter Anki-deleted cards
**Files:**
+
- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147`
- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present
@@ -410,11 +415,13 @@
Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `` closing the cards-mined section, add:
```tsx
- {hiddenCardCount > 0 && (
-
+ );
+ }
```
Place that footer immediately before the closing `` of the bordered cards-mined section, so it stays scoped to that block.
@@ -422,12 +429,14 @@
**Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`.
Concrete change near line 22:
+
```tsx
const [noteInfos, setNoteInfos] = useState