diff --git a/changes/docs-site-simplify.md b/changes/docs-site-simplify.md
new file mode 100644
index 00000000..542271e8
--- /dev/null
+++ b/changes/docs-site-simplify.md
@@ -0,0 +1,6 @@
+type: docs
+area: docs
+
+- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
+- The configuration reference now has a short explanation and a key/default table for each config block.
+- Fixed docs that no longer matched current behavior.
diff --git a/docs-site/.vitepress/theme/components/StatusLine.vue b/docs-site/.vitepress/theme/components/StatusLine.vue
index 0d523cc6..56bc77bf 100644
--- a/docs-site/.vitepress/theme/components/StatusLine.vue
+++ b/docs-site/.vitepress/theme/components/StatusLine.vue
@@ -1,10 +1,10 @@
@@ -40,8 +40,8 @@ const lastUpdated = computed(() => {
diff --git a/docs-site/.vitepress/theme/status-line.test.ts b/docs-site/.vitepress/theme/status-line.test.ts
index 61317fd4..4d33e780 100644
--- a/docs-site/.vitepress/theme/status-line.test.ts
+++ b/docs-site/.vitepress/theme/status-line.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test';
-import { formatStatusLineFilePath } from './status-line';
+import { formatStatusLineDate, formatStatusLineFilePath } from './status-line';
test('status line file path formats root home as index markdown', () => {
expect(formatStatusLineFilePath('/')).toBe('index.md');
@@ -10,7 +10,9 @@ 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');
+});
+
+test('status line date uses the local calendar day, zero padded', () => {
+ expect(formatStatusLineDate(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05');
});
diff --git a/docs-site/.vitepress/theme/status-line.ts b/docs-site/.vitepress/theme/status-line.ts
index e473e54c..e47bd82e 100644
--- a/docs-site/.vitepress/theme/status-line.ts
+++ b/docs-site/.vitepress/theme/status-line.ts
@@ -2,3 +2,10 @@ export function formatStatusLineFilePath(routePath: string): string {
if (routePath === '/') return 'index.md';
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
}
+
+// Local calendar date as YYYY-MM-DD (toISOString would give the UTC date).
+export function formatStatusLineDate(date: Date): string {
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${date.getFullYear()}-${month}-${day}`;
+}
diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md
index 5b36cd75..dab55b0c 100644
--- a/docs-site/anilist-integration.md
+++ b/docs-site/anilist-integration.md
@@ -1,137 +1,68 @@
# AniList integration
-SubMiner syncs your watch progress to [AniList](https://anilist.co). Finish an episode and it reads the title and episode number off the filename, finds the matching AniList entry, and updates your progress through the GraphQL API. A failed update retries in the background with exponential backoff.
-
-The same AniList data feeds [cover art](#cover-art) in the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
-
-[AniList](https://anilist.co) is a free anime tracking site. The **access token** is a private key SubMiner keeps so it can update your list for you. You approve it once during setup, and your AniList password never touches SubMiner.
+SubMiner updates your [AniList](https://anilist.co) watch progress when you finish an episode. The same connection supplies cover art for the stats dashboard and names for the [character dictionary](/character-dictionary).
## Setup
-AniList integration is opt-in. To enable it:
+1. Set `anilist.enabled` to `true`:
-1. Set `anilist.enabled` to `true` in your config.
-2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`).
-3. Approve access in the AniList authorization page.
-4. The callback returns to SubMiner via the `subminer://anilist-setup?...` protocol URL, and SubMiner stores the token automatically.
+ ```jsonc
+ {
+ "anilist": {
+ "enabled": true,
+ },
+ }
+ ```
-```jsonc
-{
- "anilist": {
- "enabled": true,
- "accessToken": "",
- },
-}
-```
+2. Restart SubMiner. With no token stored, it opens the AniList setup window. You can also open it from the tray (**Configure AniList**) or with `subminer app --anilist-setup`.
+3. Approve access on the AniList page. SubMiner receives the token through a `subminer://` link and stores it encrypted.
-The access token is encrypted at rest using Electron's `safeStorage` API. On Linux this defaults to `gnome-libsecret`; override the backend with `--password-store=` (for example `--password-store=basic_text`).
+If the setup window does not render, SubMiner opens the authorization page in your browser instead. To skip the flow entirely, paste a token into `anilist.accessToken`.
-If the embedded auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app.
+On Linux, the token is stored with `gnome-libsecret` by default. If your keyring is unavailable, start it (gnome-keyring or KWallet) or launch SubMiner with `--password-store=basic_text`.
-::: tip
-You can also set `anilist.accessToken` directly in config to skip the setup flow entirely. When blank, SubMiner uses the locally stored encrypted token.
-:::
+## How updates work
-## How tracking works
+An episode counts as watched after 85% of its length and at least 10 minutes of playback. SubMiner then:
-SubMiner watches playback and pushes an AniList progress update once an episode counts as watched. That means at least 85% of its duration, and at least 10 minutes either way.
+1. Reads the title, season, and episode from the file name and folder. Install [guessit](https://github.com/guessit-io/guessit) for better parsing. A folder named `Season 2` is a strong season hint.
+2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
+3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
-The update flow:
+The show must already be on your Planning or Watching list. SubMiner does not add new entries, and it never lowers your progress.
-1. **Title detection** - SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
-2. **AniList search** - The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons - sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching ` Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
-3. **Progress check** - SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
-4. **Mutation** - A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
+Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
-## Update queue and retry
+## Fixing a wrong match
-Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
+If a cover or title in the stats Library is wrong, open the title and use **Change AniList Entry**.
-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.
+If SubMiner cannot find a later season, it skips the update rather than writing progress to season 1. Pin the right entry with the character dictionary's AniList override. See [Character dictionary](/character-dictionary).
-| Parameter | Value |
-| ---------------- | ---------- |
-| Initial backoff | 30 seconds |
-| Maximum backoff | 6 hours |
-| Maximum attempts | 8 |
-| Queue capacity | 500 items |
+## Commands
-After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
+| Command | What it does |
+| ------------------------------------ | --------------------------------------- |
+| `subminer app --anilist-setup` | Open the AniList setup window |
+| `subminer app --anilist-status` | Show token state and retry queue counts |
+| `subminer app --anilist-logout` | Remove the stored token |
+| `subminer app --anilist-retry-queue` | Retry one queued update now |
-Use `--anilist-retry-queue` to manually process one ready item from the queue.
+## Options
-## Cover art
+| Key | What it does |
+| --------------------- | ------------------------------------------------------------- |
+| `anilist.enabled` | Turns on progress updates. |
+| `anilist.accessToken` | Token override. Leave empty to use the token stored by setup. |
-SubMiner fetches cover art from AniList for display in the stats dashboard. When a new video starts playing, the cover art fetcher:
-
-1. Checks the local database for cached art.
-2. If missing, parses the media title (guessit then fallback) and searches the AniList API.
-3. Downloads the cover image from the AniList CDN and caches it locally (both URL and blob).
-4. Stores AniList metadata (romaji/English titles, total episodes) alongside the cover for dashboard display.
-
-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
-
-All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
-
-## Configuration reference
-
-```jsonc
-{
- "anilist": {
- "enabled": true,
- "accessToken": "",
- "characterDictionary": {
- "maxLoaded": 3,
- "profileScope": "all",
- "collapsibleSections": {
- "description": false,
- "characterInformation": false,
- "voicedBy": false,
- },
- },
- },
-}
-```
-
-| Option | Values | Description |
-| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
-| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
-| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
-| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
-| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
-| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
-| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
-| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
-
-There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
-
-## CLI commands
-
-| Command | Description |
-| ----------------------- | ------------------------------------------------------------- |
-| `--anilist-setup` | Open AniList setup/auth flow helper window |
-| `--anilist-status` | Print current token resolution state and retry queue counters |
-| `--anilist-logout` | Clear stored AniList token from local persisted state |
-| `--anilist-retry-queue` | Process one ready retry queue item immediately |
+Character dictionary settings live under `anilist.characterDictionary` and are covered on the [Character dictionary](/character-dictionary) page. See [Configuration](/configuration#anilist) for defaults.
## Troubleshooting
-- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
-- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
-- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
-- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
-- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
-- **Cover art missing:** Cover art is fetched on a best-effort basis using title matching. If the filename is hard to parse, the search may return no results. The fetcher retries after 5 minutes.
-- **Encryption unavailable on Linux:** If you see warnings about safeStorage, try `--password-store=basic_text` as a workaround, or start your desktop keyring (gnome-keyring, KWallet).
+**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
-## Related
+**"AniList update not possible."** Add the show to your Planning or Watching list, then mark the episode watched again.
-- [Character Dictionary](/character-dictionary) - AniList-powered character name dictionary for Yomitan
-- [Configuration Reference](/configuration) - full config options
-- [Jellyfin Integration](/jellyfin-integration) - media server integration
+**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
+
+**Token errors.** Run `subminer app --anilist-status`. If the token is invalid, run `--anilist-logout`, then `--anilist-setup`.
diff --git a/docs-site/aniskip-integration.md b/docs-site/aniskip-integration.md
index ee5ff5dd..e02071f4 100644
--- a/docs-site/aniskip-integration.md
+++ b/docs-site/aniskip-integration.md
@@ -1,53 +1,39 @@
# AniSkip integration
-SubMiner looks up anime intro timings from [AniSkip](https://aniskip.com) so you can jump past the OP with one key.
-
-Intro detection runs in the SubMiner app over the mpv IPC socket. It works whenever the overlay is connected to mpv, not only at launch, and covers every local file loaded during the session including playlist advances.
+SubMiner looks up opening timestamps on [AniSkip](https://aniskip.com) so you can skip an anime's intro with one key.
## Setup
-AniSkip is enabled by default. Disable it or change the skip key in your config:
+AniSkip is on by default. To turn it off or change the key:
```jsonc
{
"mpv": {
- "aniskipEnabled": true, // default: true
+ "aniskipEnabled": true,
"aniskipButtonKey": "TAB",
},
}
```
-Both settings hot-reload: changing them in your config takes effect immediately without restarting playback or mpv.
-
-For best title and episode detection, install [`guessit`](https://github.com/guessit-io/guessit):
+Both settings apply immediately, without restarting mpv. For better title and episode detection, install [guessit](https://github.com/guessit-io/guessit):
```bash
python3 -m pip install --user guessit
```
-Without `guessit`, SubMiner falls back to its own filename parser. That handles the usual release naming, but unusual formats slip past it.
+## Usage
-## How it works
+When a local file loads, SubMiner reads the title and episode from the file name, finds the show on MyAnimeList, and asks AniSkip for the intro's timestamps. Streams and URLs are skipped.
-On each local file load:
+If AniSkip has an intro, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapters. When the intro starts, mpv shows "You can skip by pressing TAB" (with your key) for 3 seconds. Press the key any time during the intro to jump to its end.
-1. SubMiner infers the anime title, season, and episode number from the filename and path (using `guessit` if available, otherwise the built-in parser). Remote URLs are skipped entirely.
-2. The title is matched against MyAnimeList to resolve a MAL id.
-3. SubMiner queries the AniSkip API for an OP skip interval for that MAL id and episode.
-4. If an interval is found, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapter markers to the current file and binds the skip key (`mpv.aniskipButtonKey`, default `TAB`).
-5. At the start of the intro, an OSD prompt appears for 3 seconds: `You can skip by pressing TAB` (reflects your configured key). Pressing the key at any point during the intro seeks to the intro end.
-
-When a custom key (other than `TAB` or `y-k`) is configured, the legacy `y-k` chord is also bound as a fallback skip trigger.
-
-Results are cached per file for the app session. Only a definitive "no intro found" is cached, so a failed lookup gets retried on the next load rather than sticking. If mpv reloads the same file, SubMiner re-applies the chapter markers without hitting the API again.
+With a custom key other than `TAB` or `y-k`, `y-k` also skips.
## Triggering from mpv
-AniSkip actions are also reachable from mpv script-messages:
+| Command | What it does |
+| ----------------------------------------- | ------------------------------------------------------- |
+| `script-message subminer-skip-intro` | Skip to the end of the intro |
+| `script-message subminer-aniskip-refresh` | Look up the current file again, ignoring cached results |
-| 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 |
-
-The SubMiner app handles both over the IPC socket.
+Use `subminer-aniskip-refresh` after a lookup failed or matched the wrong show.
diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md
index 11c8747c..785e9aae 100644
--- a/docs-site/anki-integration.md
+++ b/docs-site/anki-integration.md
@@ -1,51 +1,34 @@
# Anki integration
-SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
-This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
+SubMiner talks to Anki through the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on. It fills new cards with the sentence, an audio clip, and a screenshot, and can create sentence cards and merge duplicate words. It is built for the [Lapis](https://github.com/donkuri/lapis), [Kiku](https://kiku.youyoumu.my.id/), and [Senren](https://github.com/BrenoAqua/Senren) note types, but works with any note type once you map its fields.
-::: tip New to these terms?
-
-- **Anki** is the flashcard app where your study cards live.
-- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
-- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
-- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
- :::
+For the day-to-day flow, see [Mining workflow](/mining-workflow). Every key on this page, with its default, is listed in the [AnkiConnect config reference](/configuration#ankiconnect).
## Prerequisites
1. Install [Anki](https://apps.ankiweb.net/).
-2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on (code: `2055492159`).
-3. Keep Anki running while using SubMiner.
+2. Install AnkiConnect (add-on code `2055492159`).
+3. Install FFmpeg and make sure it is on your `PATH`. SubMiner uses it for audio and images.
+4. Keep Anki running while you mine.
-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.
+If you changed AnkiConnect's port, set `ankiConnect.url` to match.
-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`.
+## How cards get filled
-## Auto-enrichment transport
+When Yomitan adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
-When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
+- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. Yomitan sends notes through it, and SubMiner fills each one right after Anki accepts it.
+- **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds.
-**Proxy mode** (default) - SubMiner runs a small local server between Yomitan and Anki. Yomitan sends the new card to SubMiner, SubMiner fills in the media fields, and the finished card goes on to Anki. There is no polling delay.
+Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead.
-**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether new cards showed up, then fills them in. Less to configure, at the cost of roughly a 3 second delay.
+`ankiConnect.deck` limits enrichment and duplicate checks to one deck. If it is empty, SubMiner uses Yomitan's mining deck when it can read it, and otherwise searches all decks.
-Use proxy mode unless your Yomitan runs in a browser rather than the bundled instance, in which case polling is the simpler path.
-
-In both modes, the enrichment workflow is the same:
-
-1. Checks if a duplicate expression already exists (for field grouping).
-2. Updates the sentence field with the current subtitle.
-3. Generates and uploads audio and image media.
-4. Writes metadata to the miscInfo field.
-
-Polling mode uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it searches all decks (`added:1`). In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect; stats-dashboard mining also falls back to Yomitan's mining deck when `ankiConnect.deck` is empty.
-Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
-
-### Proxy mode setup (Yomitan / texthooker)
+### Proxy mode setup (Yomitan / texthooker) {#proxy-mode-setup-yomitan-texthooker}
```jsonc
"ankiConnect": {
- "url": "http://127.0.0.1:8765", // real AnkiConnect
+ "url": "http://127.0.0.1:8765",
"proxy": {
"enabled": true,
"host": "127.0.0.1",
@@ -55,373 +38,183 @@ Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
}
```
-Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
+Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to AnkiConnect directly.
-When SubMiner loads the bundled Yomitan extension, it also attempts to update the **currently active Yomitan profile**'s Anki server to the active SubMiner endpoint (falling back to `profiles[0]` if the active-profile index is invalid):
+- **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`.
+- **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine.
-- proxy URL when `ankiConnect.proxy.enabled` is `true`
-- direct `ankiConnect.url` when proxy mode is disabled
+### Proxy troubleshooting
-To avoid clobbering custom setups, this auto-update only changes the profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
+If cards are not getting filled:
-For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
+1. Check that the proxy is listening while SubMiner runs:
-### Browser/Yomitan external setup (separate profile)
+ ```bash
+ ss -ltnp | grep 8766
+ ```
-If you want SubMiner to use proxy mode without touching your main/default Yomitan profile, create or select a separate Yomitan profile just for SubMiner and set its Anki server to the proxy URL.
+2. Check that requests pass through to Anki:
-That profile isolation gives you both benefits:
+ ```bash
+ curl -sS http://127.0.0.1:8766 \
+ -H 'content-type: application/json' \
+ -d '{"action":"version","version":2}'
+ ```
-- SubMiner can auto-enrich immediately via proxy.
-- Your default Yomitan profile keeps its existing Anki server setting.
-
-In Yomitan, go to Settings → Profile and:
-
-1. Create a profile for SubMiner (or choose one dedicated profile).
-2. Open Anki settings for that profile.
-3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
-4. Save and make that profile active when using SubMiner.
-
-This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets the active profile when its server is blank or still default.
-
-### Proxy troubleshooting (quick checks)
-
-If auto-enrichment appears to do nothing:
-
-1. Confirm proxy listener is running while SubMiner is active:
-
-```bash
-ss -ltnp | rg 8766
-```
-
-2. Confirm requests can pass through the proxy:
-
-```bash
-curl -sS http://127.0.0.1:8766 \
- -H 'content-type: application/json' \
- -d '{"action":"version","version":2}'
-```
-
-3. Check the log sinks in `~/.config/SubMiner/logs/`:
-
-- App runtime log: `app-YYYY-MM-DD.log`
-- Launcher log: `launcher-YYYY-MM-DD.log`
-- mpv log: `mpv-YYYY-MM-DD.log`
-
-4. Check that the config JSONC parses and the logging shape is right:
-
-```jsonc
-"logging": {
- "level": "debug"
-}
-```
-
-`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
+3. Read the app log (`app-YYYY-MM-DD.log`) in the logs folder. See [Troubleshooting](/troubleshooting) for where logs live.
## Field mapping
-SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`:
+`ankiConnect.fields` maps SubMiner's data to fields on your note type.
+
+| Key | Receives |
+| ------------------ | ------------------------------------------------------------------------- |
+| `fields.word` | The mined word |
+| `fields.audio` | Sentence audio cut from the video |
+| `fields.wordAudio` | Read only: Yomitan's word audio, used to time animated images (see below) |
+| `fields.image` | Screenshot or animated clip |
+| `fields.sentence` | Subtitle text |
+| `fields.miscInfo` | Text from `ankiConnect.metadata.pattern` |
```jsonc
"ankiConnect": {
"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)
+ "audio": "SentenceAudio",
+ "sentence": "Sentence"
}
}
```
-`fields.audio` receives the **sentence** audio SubMiner cuts from the video, not word audio. Yomitan writes its own dictionary audio when you mine, so point this at a separate field such as `SentenceAudio` to keep the two apart. The built-in default is still `ExpressionAudio`, which collides with Yomitan on note types that use that field for word audio.
+Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped.
-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.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
-`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.
+`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
-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.
+`ankiConnect.metadata.pattern` builds the MiscInfo text. Tokens: `%f` file name, `%F` file name with extension, `%t` timestamp, `%T` timestamp with milliseconds, ` ` line break.
-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, ` ` newline).
+## Media
-### Minimal config
+| Key | What it does |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
+| `media.generateAudio` | Cut sentence audio (MP3) from the subtitle's start and end time |
+| `media.audioPadding` | Seconds added before and after the clip |
+| `media.fallbackDuration` | Clip length when the subtitle has no timing |
+| `media.maxMediaDuration` | Longest allowed clip, in seconds (`0` removes the cap) |
+| `media.normalizeAudio` | Normalize clip loudness |
+| `media.mirrorMpvVolume` | Scale the clip by mpv's current volume, so quiet playback gives quiet clips |
+| `media.generateImage` | Capture an image |
+| `media.imageType` | `static` for one frame, `avif` for an animated clip of the line |
+| `media.imageFormat` | Static format: `jpg`, `png`, or `webp` |
+| `media.imageQuality` | Static image quality |
+| `media.imageMaxWidth` / `Height` | Static size limit (`0` keeps source size) |
+| `media.animatedFps` | Animated clip frame rate |
+| `media.animatedMaxWidth` / `Height` | Animated size limit (`0` keeps aspect ratio) |
+| `media.animatedCrf` | Animated quality, `0` to `63`, lower is better |
+| `media.syncAnimatedImageToWordAudio` | Hold the first frame for the length of the word audio in `fields.wordAudio`, so the motion starts with the sentence audio |
+| `media.reviewTiming` | Pause and let you adjust the clip before media is made (see below) |
-If you only want sentence and audio on your cards:
+Animated AVIF needs an FFmpeg build with an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`).
-```jsonc
-"ankiConnect": {
- "enabled": true,
- "fields": {
- "sentence": "Sentence",
- "audio": "SentenceAudio"
- }
-}
-```
+Media settings apply to the next card without a restart.
-## Media generation
+### Review media timing
-SubMiner shells out to FFmpeg for audio clips and screenshots, so FFmpeg has to be installed and on `PATH`.
+With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
-For remote streams such as Jellyfin playback, SubMiner downloads the clip's time window once into a temporary Matroska file (a stream copy, no re-encoding) and reads the timing review waveform, audio preview, audio, and image from that file instead of fetching the stream again for each step. The window covers the clip plus padding, plus the visible timeline in timing review, and grows when you reveal more of the timeline. It is deleted when a different window replaces it, after ten minutes without use, or when SubMiner exits. If the download fails, media generation reads the remote stream directly as before.
+The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The Line end rail still marks the subtitle's own end.
-### Audio
+| Action | How |
+| ------------------------ | --------------------------------------------------------------------- |
+| Trim | Drag either edge, or click the waveform to move the nearer edge there |
+| Nudge an edge | Arrow keys on a focused edge (100 ms, `Shift` for 500 ms) |
+| Slide the clip | Drag the middle |
+| Show more timeline | Earlier / Later |
+| Pick the screenshot | Screenshot slider or Frame buttons (static images only) |
+| Add previous / next line | `P` / `N` (`Shift+P` / `Shift+N` removes) |
+| Preview | `Space` |
+| Confirm | `Enter` |
+| Cancel | `Escape` |
-Audio is extracted from the video file using the subtitle's start and end timestamps. Padding is opt-in; keep it at `0` when you want sentence audio to start exactly at the mined sentence.
+The confirmed range is used as is, with no extra padding. Added lines go into the sentence field. Reset restores the original timing and removes added lines.
-```jsonc
-"ankiConnect": {
- "media": {
- "generateAudio": true,
- "normalizeAudio": true, // normalize generated clip loudness
- "mirrorMpvVolume": true, // apply the current mpv volume level
- "reviewTiming": false, // review and adjust timing before media generation
- "audioPadding": 0, // optional seconds before and after subtitle timing
- "maxMediaDuration": 30 // cap total duration in seconds
- }
-}
-```
+When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card.
-Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized to -23 LUFS by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. When subtitle timing is missing, clips fall back to `media.fallbackDuration` seconds (default `3`). Changing these settings applies to the next extraction without restarting SubMiner.
+### Update behavior
-`mirrorMpvVolume` is also enabled by default. Immediately before extracting each playback-overlay card's audio, SubMiner reads mpv's numeric `volume` and applies mpv's cubic software-volume curve after loudness normalization. For example, mpv volume `50` produces `0.5³ = 0.125` gain. Amplified output above mpv volume `100` is limited to a `-1 dBFS` ceiling before MP3 encoding to prevent clipping. It ignores mpv's separate `mute` state. If the volume property is missing, invalid, or unavailable, extraction continues with unity scaling; disabling this option skips the query and volume filter. Changing this setting applies to the next extraction without restarting SubMiner. YouTube cards queued for a background media-cache download retain the volume captured when the card was mined. Stats-dashboard mining does not currently have access to the active mpv property client, so it does not apply mpv volume scaling.
+| Key | What it does |
+| ----------------------------- | ---------------------------------------------------- |
+| `behavior.overwriteAudio` | Replace existing audio instead of adding to it |
+| `behavior.overwriteImage` | Replace the existing image instead of adding to it |
+| `behavior.mediaInsertMode` | `append` or `prepend` new media when not overwriting |
+| `behavior.autoUpdateNewCards` | Fill new Yomitan notes automatically |
+| `behavior.highlightWord` | Bold the mined word in the sentence field |
+| `behavior.notificationType` | `overlay`, `system`, `both`, or `none` |
-The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`.
+Manual clipboard updates (`Ctrl/Cmd+V`) always replace the sentence audio, whatever `overwriteAudio` says.
-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.
+## Sentence cards (Lapis) {#sentence-cards-lapis}
-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.
-
-The range you confirm is used exactly as-is; SubMiner does not add audio padding a second time. Static screenshots take its midpoint, and animated AVIF clips cover the whole range.
-
-**Pulling in adjacent lines.** Press `P` or `N`, or use the Prev and Next steppers above the sentence preview, to add the previous or next subtitle line. Repeat for as many lines as exist. Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined one highlighted, so you always see the sentence field before confirming. The clip bounds and the waveform rails follow the outermost added line, keeping the review's audio padding.
-
-Confirming writes the combined lines to the sentence field. Reset drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise you only get lines that already played. A clip capped by `media.maxMediaDuration` still keeps the full combined sentence even when the audio cannot stretch to cover every added line.
-
-**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
-
-When word-card enrichment changes the sentence context, including an expanded timing-review selection, SubMiner regenerates `SentenceFurigana` from the final sentence. Unchanged sentences keep their existing furigana formatting. If generation fails, SubMiner clears stale furigana so compatible templates can fall back to `Sentence`.
-
-Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
-
-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.
-
-```jsonc
-"ankiConnect": {
- "media": {
- "generateImage": true,
- "imageType": "static",
- "imageFormat": "jpg", // "jpg", "png", or "webp"
- "imageQuality": 92, // 1–100
- "imageMaxWidth": 0, // 0 = preserve source resolution
- "imageMaxHeight": 0
- }
-}
-```
-
-### Animated clips (AVIF)
-
-SubMiner can produce an animated AVIF spanning the subtitle duration instead of a still frame.
-
-```jsonc
-"ankiConnect": {
- "media": {
- "generateImage": true,
- "imageType": "avif",
- "animatedFps": 10,
- "animatedMaxWidth": 640,
- "animatedMaxHeight": 0, // 0 = preserve aspect ratio
- "animatedCrf": 35 // 0–63, lower = better quality
- }
-}
-```
-
-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
-
-```jsonc
-"ankiConnect": {
- "behavior": {
- "overwriteAudio": true, // replace existing audio, or append
- "overwriteImage": true, // replace existing image, or append
- "mediaInsertMode": "append", // "append" or "prepend" to field content
- "autoUpdateNewCards": true, // auto-update when new card detected
- "highlightWord": true, // bold the mined word inside the sentence field
- "notificationType": "overlay" // "overlay", "system", "both", or "none"
- }
-}
-```
-
-`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
-
-When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
-
-`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
-
-## Sentence cards (Lapis)
-
-SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types.
-
-::: warning Required config
-Sentence card creation and audio card marking require a non-empty `ankiConnect.isLapis.sentenceCardModel` naming a note type that exists in Anki (default: `"Lapis"`). If the model is empty or missing, the `Ctrl/Cmd+S` and `Ctrl/Cmd+Shift+A` shortcuts will not create cards.
-:::
+`Ctrl/Cmd+S` creates a standalone sentence card from the current line, and `Ctrl/Cmd+Shift+S` then a digit combines several lines. The card uses the note type named in `ankiConnect.isLapis.sentenceCardModel`, which must exist in Anki. If it is empty, no card is created.
```jsonc
"ankiConnect": {
"isLapis": {
"enabled": true,
- "sentenceCardModel": "Lapis" // default; point at your Lapis/Kiku note type
+ "sentenceCardModel": "Lapis"
}
}
```
-Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in.
-
-The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatible `Sentence` and `SentenceAudio` fields. This does not affect the configured fields used to enrich normal word cards.
-
-To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
+Sentence cards and audio cards (`Ctrl/Cmd+Shift+A`) always write to the `Sentence` and `SentenceAudio` fields. Normal word cards keep using your `ankiConnect.fields` mapping.
## Word card type (Kiku/Lapis)
-Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
+When `isKiku` or `isLapis` is enabled, SubMiner sets a card-type flag on word cards it fills. Choose the flag with `ankiConnect.lapisKiku.wordCardKind`:
-```jsonc
-"ankiConnect": {
- "isKiku": { "enabled": true },
- "lapisKiku": {
- "wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
- }
-}
-```
+| Value | Flag |
+| ------------------- | ----------------------- |
+| `word-and-sentence` | `IsWordAndSentenceCard` |
+| `click` | `IsClickCard` |
+| `sentence` | `IsSentenceCard` |
+| `audio` | `IsAudioCard` |
+| `none` | Leaves flags alone |
-`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
+The other card-type flags are cleared. Sentence cards and audio cards keep their own flag.
-## Field grouping (Kiku/Senren)
+## Field grouping (Kiku/Senren) {#field-grouping-kiku-senren}
-When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
+When you mine a word that already has a card, SubMiner can merge the new card into the old one. The sentence, audio, image, and MiscInfo from both cards are kept as grouped entries, and the template lets you switch between them. This works with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls it [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md)).
+
+Enable one of them. They write different markup to the same fields, so only one can be on. If both are enabled, Kiku is used and SubMiner logs a config warning.
```jsonc
"ankiConnect": {
"isKiku": {
"enabled": true,
- "fieldGrouping": "manual", // "auto", "manual", or "disabled"
- "deleteDuplicateInAuto": true // delete new card after auto-merge
+ "fieldGrouping": "manual",
+ "deleteDuplicateInAuto": true
}
}
```
-For Senren note types, enable `isSenren` instead. Kiku and Senren write incompatible markup into the same fields, so only one can be enabled at a time; if both are enabled, Kiku wins and a config warning is emitted.
+For Senren, use the same keys under `isSenren`.
-```jsonc
-"ankiConnect": {
- "isSenren": {
- "enabled": true,
- "fieldGrouping": "auto", // "auto" (default), "manual", or "disabled"
- "deleteDuplicateInAuto": true // delete new card after auto-merge
- }
-}
-```
+| `fieldGrouping` | Behavior |
+| --------------- | ------------------------------------------------------------------------------- |
+| `disabled` | No duplicate check |
+| `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card |
+| `manual` | Show both cards, let you choose which to keep and preview the merge |
-### Modes
+The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself.
-**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
+| Key | Action |
+| ----------- | ------------------------------------- |
+| `1` / `2` | Keep card 1 or card 2 |
+| `Enter` | Confirm |
+| `Backspace` | Back from the merge preview |
+| `Esc` | Cancel and leave both cards unchanged |
-**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both cards' sentences, audio clips, and images are preserved as grouped entries. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
+## Config validation
-**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
-
-### What gets merged
-
-| Field | Merge behavior |
-| -------- | ----------------------------------------------- |
-| Sentence | Both cards' sentences kept as grouped entries |
-| Audio | Both cards' `[sound:...]` entries kept |
-| Image | Both cards' images kept |
-| MiscInfo | Both cards' source info kept as grouped entries |
-
-Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
-
-The merge markup depends on the note type. Kiku entries are wrapped in `` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries use `group` spans when ordinal order is sufficient and numbered `groupN` spans when they need an absolute scene target. Audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and source `groupN` spans are rebased after the kept note's existing audio scenes.
-
-### Keyboard shortcuts in the modal
-
-| Key | Action |
-| ----------- | ---------------------------------- |
-| `1` / `2` | Select card 1 or card 2 to keep |
-| `Enter` | Confirm selection |
-| `Backspace` | Go back from the merge preview |
-| `Esc` | Cancel (keep both cards unchanged) |
-
-## Full config example
-
-```jsonc
-{
- "ankiConnect": {
- "enabled": true,
- "url": "http://127.0.0.1:8765",
- "pollingRate": 3000,
- "deck": "",
- "tags": ["SubMiner"],
- "proxy": {
- "enabled": true, // default
- "host": "127.0.0.1",
- "port": 8766,
- "upstreamUrl": "http://127.0.0.1:8765",
- },
- "fields": {
- "word": "Expression",
- "audio": "SentenceAudio",
- "image": "Picture",
- "sentence": "Sentence",
- "miscInfo": "MiscInfo",
- },
- "media": {
- "generateAudio": true,
- "generateImage": true,
- "imageType": "static",
- "imageFormat": "jpg",
- "imageQuality": 92,
- "normalizeAudio": true,
- "mirrorMpvVolume": true,
- "audioPadding": 0,
- "maxMediaDuration": 30,
- },
- "behavior": {
- "overwriteAudio": true,
- "overwriteImage": true,
- "mediaInsertMode": "append",
- "autoUpdateNewCards": true,
- "notificationType": "overlay",
- },
- "metadata": {
- "pattern": "[SubMiner] %f (%t)",
- },
- "isKiku": {
- "enabled": false,
- "fieldGrouping": "disabled",
- "deleteDuplicateInAuto": true,
- },
- "isLapis": {
- "enabled": false,
- "sentenceCardModel": "Lapis",
- },
- },
-}
-```
+Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`.
diff --git a/docs-site/architecture.md b/docs-site/architecture.md
index 0aa87cc3..f4d9531b 100644
--- a/docs-site/architecture.md
+++ b/docs-site/architecture.md
@@ -1,148 +1,55 @@
# Architecture
-This page is a contributor-facing architecture summary. Canonical internal architecture guidance lives in `docs/architecture/README.md` at the repo root.
+A contributor-facing map of how SubMiner is put together. The canonical internal guidance, including domain ownership and layering rules, is [`docs/architecture/README.md`](https://github.com/ksyasuda/SubMiner/blob/main/docs/architecture/README.md) in the repo.
-SubMiner is split into three cooperating runtimes:
+SubMiner runs as three cooperating runtimes:
-- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
-- Launcher CLI (`launcher/`) for mpv/app command workflows.
-- mpv Lua plugin (`plugin/subminer/main.lua` + module files) for player-side controls and IPC handoff.
+- the Electron desktop app (`src/`): overlay, UI, and runtime orchestration
+- the launcher CLI (`launcher/`): mpv and app command workflows
+- the mpv Lua plugin (`plugin/subminer/`): player-side controls and handoff to the app
-Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
+Inside the app, `src/main.ts` is a composition root. It owns wiring and state, and delegates behavior to small runtime and domain modules that can be tested without Electron or mpv.
-## Goals
-
-- Keep behavior stable while reducing coupling.
-- Prefer small, single-purpose units that can be tested in isolation.
-- Keep `main.ts` focused on wiring and state ownership, not implementation detail.
-- Follow Unix-style composability:
- - each service does one job
- - services compose through explicit inputs/outputs
- - orchestration is separate from implementation
-
-## Project structure
+## Project layout
```text
-launcher/ # Standalone CLI launcher wrapper and mpv helpers
- commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough/
- # dictionary/history/logs/stats/update)
- config/ # Launcher config parsers + CLI parser builder
- main.ts # Launcher entrypoint and command dispatch
-plugin/
- subminer/ # Modular mpv plugin (main · init · bootstrap · lifecycle · process
- # state · messages · hover · ui · options · environment · log
- # binary · session_bindings · version)
+launcher/
+ main.ts # entrypoint and command dispatch
+ commands/ # one module per subcommand (playback, jellyfin, stats, sync, ...)
+ config/ # launcher config readers and CLI parser
+plugin/subminer/ # mpv plugin; main.lua loads init.lua, which boots the other modules
src/
- main-entry.ts # Background-mode bootstrap wrapper before loading main.js
- main.ts # Entry point - delegates to runtime composers/domain modules
- preload.ts # Electron preload bridge
- types.ts # Shared type definitions
- main/ # Main-process composition/runtime adapters
- boot/ # Pre-ready boot helpers
- app-lifecycle.ts # App lifecycle + app-ready runtime runner factories
- character-dictionary-runtime.ts # Character-dictionary orchestration/public runtime API
- cli-runtime.ts # CLI command runtime service adapters
- config-validation.ts # Startup/hot-reload config error formatting and fail-fast helpers
- dependencies.ts # Shared dependency builders for IPC/runtime services
- ipc-runtime.ts # IPC runtime registration wrappers
- overlay-runtime.ts # Overlay modal routing + active-window selection
- overlay-shortcuts-runtime.ts # Overlay keyboard shortcut handling
- overlay-visibility-runtime.ts # Overlay visibility + tracker-driven bounds service
- frequency-dictionary-runtime.ts # Frequency dictionary runtime adapter
- jlpt-runtime.ts # JLPT dictionary runtime adapter
- media-runtime.ts # Media path/title/subtitle-position runtime service
- startup.ts # Startup bootstrap dependency builder
- startup-lifecycle.ts # Lifecycle runtime runner adapter
- state.ts # Application runtime state container + reducer transitions
- subsync-runtime.ts # Subsync command runtime adapter
- character-dictionary-runtime/ # Character-dictionary fetch/build/cache modules + focused tests
- runtime/
- composers/ # High-level composition clusters used by main.ts
- domains/ # Domain barrel exports (startup/overlay/mpv/jellyfin/...)
- registry.ts # Domain registry consumed by main.ts
- core/
- services/ # Focused runtime services (Electron adapters + pure logic)
- anilist/ # AniList token store/update queue/update helpers
- immersion-tracker/ # Immersion persistence/session/metadata modules
- tokenizer/ # Tokenizer stage modules (selection/enrichment/annotation)
- utils/ # Pure helpers and coercion/config utilities
- cli/ # CLI parsing and help output
- config/ # Config defaults/definitions, loading, parse, resolution pipeline
- definitions/ # Domain-specific defaults + option registries
- resolve/ # Domain-specific config resolution pipeline stages
- shared/ipc/ # Cross-process IPC channel constants + payload validators
- renderer/ # Overlay renderer (modularized UI/runtime)
- handlers/ # Keyboard/mouse/gamepad interaction modules
- modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
- # changelog, character dictionary, playlist browser, subtitle
- # sidebar, YouTube track picker, controller config/debug/select)
- positioning/ # Subtitle position controller (drag-to-reposition)
- settings/ # Settings window UI (model, controls, markup)
- types/ # Domain type modules (anki, config, integrations, ...)
- window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows)
- jimaku/ # Jimaku API integration helpers
- subsync/ # Subtitle sync (alass/ffsubsync) helpers
- anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
+ main-entry.ts # bootstrap wrapper that runs before main.js
+ main.ts # composition root
+ preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
+ main/ # main-process runtime modules and IPC/CLI wiring
+ boot/ # pre-ready boot helpers
+ runtime/composers/ # larger runtime clusters assembled for main.ts
+ runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
+ core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
+ core/utils/ # pure helpers
+ shared/ipc/ # IPC channel constants and payload validators
+ renderer/ # overlay renderer: subtitle rendering, input handlers, modals
+ config/ # definitions/ (defaults + option registries) and resolve/ (resolution pipeline)
+ cli/ # app CLI parsing and help output
+ settings/, syncui/ # settings and sync windows
+ window-trackers/ # Hyprland, Sway, X11, macOS, and Windows trackers
+ anki-integration/ # AnkiConnect proxy and note-update workflow
+ jimaku/, subsync/, tsukihime/ # integration helpers
+ types/ # shared domain types
+stats/ # stats dashboard UI (Vite)
+vendor/ # Yomitan fork, texthooker-ui, JLPT vocab
```
-### Service layer (`src/core/services/`)
+A few ownership notes that are hard to guess from file names:
-- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
-- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
-- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
-- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
-- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules (including `parser-enrichment-worker-runtime.ts` for async MeCab enrichment and `yomitan-parser-runtime.ts`)
-- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
-- **Anki integration (repo `src/` root, not under `core/services/`):** `src/anki-integration.ts`, `src/anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `src/anki-integration/note-update-workflow.ts`
-- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
-- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
+- mpv access is split into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and property modules under `src/core/services/`.
+- The renderer keeps `renderer.ts` to orchestration. Keyboard, mouse, and gamepad input live in `renderer/handlers/`, and each modal flow has its own file in `renderer/modals/`.
+- AniSkip intro detection runs in the app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the mpv IPC socket. The plugin does not handle it.
-### Renderer layer (`src/renderer/`)
+## Component diagram
-The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delegated to per-concern modules.
-
-```text
-src/renderer/
- renderer.ts # Entrypoint/orchestration only
- context.ts # Shared runtime context contract
- state.ts # Centralized renderer mutable state (visible overlay only)
- error-recovery.ts # Global renderer error boundary + recovery actions
- overlay-content-measurement.ts # Reports rendered bounds to main process
- subtitle-render.ts # Primary/secondary subtitle rendering + style application
- positioning.ts # Facade export for positioning controller
- yomitan-popup.ts # Yomitan popup iframe detection utilities
- positioning/
- controller.ts # Subtitle drag-position controller
- position-state.ts # Position state helpers (yPercent)
- handlers/
- keyboard.ts # Keybindings, chord handling, modal key routing
- mouse.ts # Hover/drag behavior, selection + observer wiring
- gamepad-controller.ts # Gamepad/controller input handling
- controller-binding-capture.ts # Controller binding capture flow
- modals/
- jimaku.ts # Jimaku modal flow
- kiku.ts # Kiku field-grouping modal flow
- runtime-options.ts # Runtime options modal flow
- session-help.ts # Keyboard shortcuts/help modal flow
- subsync.ts # Manual subsync modal flow
- character-dictionary.ts # Character dictionary modal flow
- playlist-browser.ts # Playlist browser modal flow
- subtitle-sidebar.ts # Subtitle sidebar modal flow
- youtube-track-picker.ts # YouTube subtitle track picker
- controller-*.ts # Controller config/debug/select modals
- utils/
- dom.ts # Required DOM lookups + typed handles
- platform.ts # Layer/platform capability detection
-```
-
-### Launcher + plugin runtimes
-
-- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
-- `plugin/subminer/main.lua` is the mpv entrypoint: it sets up the module path and loads `init.lua`, a thin shim that boots the modular Lua files: `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `session_bindings.lua` (configurable session keybindings), `version.lua` (version metadata). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
-
-## Flow diagram
-
-The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
+The main process drives one primary overlay window plus modal surfaces. Primary and secondary subtitle layers render in the same overlay renderer, connected to the main process through `preload.ts`. The launcher and mpv plugin run as separate processes and talk to the app through sockets or CLI passthrough.
```mermaid
flowchart TB
@@ -225,63 +132,39 @@ flowchart TB
## Composition pattern
-Most runtime code follows a dependency-injection pattern:
+Runtime code uses dependency injection:
-1. Define a service interface in `src/core/services/*`.
-2. Keep core logic in pure or side-effect-bounded functions.
-3. Build runtime deps in `src/main/` composition modules; extract an adapter/helper only when it adds meaningful behavior or reuse.
-4. Call the service from lifecycle/command wiring points.
+1. Put the logic in a service under `src/core/services/`, as pure or side-effect-bounded functions.
+2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
+3. Call the service from lifecycle or command wiring.
-The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`:
+`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. Many handlers take a `*MainDeps` object built by a `createBuild*MainDepsHandler` builder, which keeps side effects out of the unit under test.
-- `startup.ts` - argv/env processing and bootstrap flow
-- `app-lifecycle.ts` - Electron lifecycle event registration
-- `startup-lifecycle.ts` - app-ready initialization sequence
-- `state.ts` - centralized application runtime state container
-- `ipc-runtime.ts` - IPC channel registration and handler wiring
-- `cli-runtime.ts` - CLI command parsing and dispatch
-- `overlay-runtime.ts` - overlay window selection and modal state management
-- `subsync-runtime.ts` - subsync command orchestration
-- `runtime/composers/anilist-tracking-composer.ts` - AniList media tracking/probe/retry wiring
-- `runtime/composers/jellyfin-runtime-composer.ts` - Jellyfin config/client/playback/command/setup composition wiring
-- `runtime/composers/mpv-runtime-composer.ts` - MPV event/factory/tokenizer/warmup wiring
+Composers declare their inputs with `ComposerInputs` and results with `ComposerOutputs` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
-Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`:
+### IPC boundary
-- composer input surfaces are declared with `ComposerInputs` so required dependencies cannot be omitted at compile time
-- composer outputs are declared with `ComposerOutputs` to keep result contracts explicit and stable
-- builder return payload extraction should use shared type helpers instead of inline ad-hoc inference
+Channel names live in `src/shared/ipc/contracts.ts` and payload validators in `src/shared/ipc/validators.ts`. Renderer payloads are validated at the IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before any domain handler runs. See [IPC + runtime contracts](/ipc-contracts) for the full rules.
-This keeps side effects explicit and makes behavior easy to unit-test with fakes.
+### Runtime state ownership
-Additional conventions in the current code:
+Some domains, such as AniList token, queue, and media-guess state, use reducer-style transitions:
-- `main.ts` uses `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`) to access domain handlers (`startup`, `overlay`, `mpv`, `ipc`, `shortcuts`, `anilist`, `jellyfin`, `mining`) without importing every runtime module directly.
-- Domain barrels in `src/main/runtime/domains/*` re-export runtime handlers + main-deps builders, while composers in `src/main/runtime/composers/*` assemble larger runtime clusters.
-- Many runtime handlers accept `*MainDeps` objects generated by `createBuild*MainDepsHandler` builders to isolate side effects and keep units testable.
-
-### IPC contract + validation boundary
-
-- Central channel constants live in `src/shared/ipc/contracts.ts` and are consumed by both main (`ipcMain`) and renderer preload (`ipcRenderer`) wiring.
-- Runtime payload parsers/type guards live in `src/shared/ipc/validators.ts`.
-- Rule: renderer-supplied payloads must be validated at IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before calling domain handlers.
-- Malformed invoke payloads return explicit structured errors (for example `{ ok: false, error: ... }`) and malformed fire-and-forget payloads are ignored safely.
-
-### Runtime state ownership (migrated domains)
-
-For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
-
-- Composition/runtime modules own mutable state cells and expose narrow `get*`/`set*` accessors.
-- Domain handlers do not mutate foreign state directly; they call explicit transition helpers that encode invariants.
-- Transition helpers may sync derived counters/snapshots, but must preserve non-owned metadata unless the transition explicitly owns that metadata.
-- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
-- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
+- Composition modules own the mutable state and expose narrow `get*`/`set*` accessors.
+- Handlers change another domain's state only through its transition helpers in `src/main/state.ts`, never by mutating the object directly.
+- A transition may update derived counters or snapshots, but must leave metadata it does not own untouched.
+- Tests for these domains check both the fields that should change and the ones that should not.
## Playback startup flow
-Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step - the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
+A SubMiner-managed launch (the `subminer` launcher, the app's own playback, or the packaged Windows shortcut) starts mpv, injects the plugin, and brings up the overlay. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` has no settings, so command-line options always win.
-Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running - or for explicit `--start-overlay` and YouTube flows - the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below.
+Once mpv is up, exactly one of two triggers starts the overlay:
+
+- On a first launch, the launcher sets `auto_start=yes` and the plugin's `file-loaded` hook starts the app once the socket is ready.
+- When the app is already running, or for `--start-overlay` and YouTube flows, the launcher attaches over the app control socket and suppresses the plugin's auto-start.
+
+Both paths end in the same app bring-up, which then runs the program lifecycle below.
```mermaid
flowchart TB
@@ -312,17 +195,17 @@ flowchart TB
Conn --> Show["Transparent overlay over mpv Yomitan lookup · mine"]:::overlay
```
-The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
+The sockets in this flow are described in [IPC + runtime contracts](./ipc-contracts#runtime-sockets).
## Program lifecycle
-- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
-- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
-- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to mpv subtitle/playback properties via `observe_property`), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
-- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
-- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
-- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through the `SubtitleProcessingController` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
-- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
+1. **Module init.** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs services, and wires dependencies. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
+2. **Startup.** `--generate-config` writes the template and exits. Otherwise `app-lifecycle.ts` takes the single-instance lock and registers Electron lifecycle hooks.
+3. **App ready.** `composeAppReadyRuntime()` reloads config strictly, resolves keybindings, creates the `MpvIpcClient` (which connects and observes subtitle and playback properties), and starts the runtime options manager, subtitle timing tracker, and immersion tracker.
+4. **Overlay.** `initializeOverlayRuntime()` creates the overlay window, registers global shortcuts, and tracks mpv's window bounds through the active window tracker. `src/main/runtime/overlay-mpv-sub-visibility.ts` hides mpv's own subtitles while the overlay shows them.
+5. **Background warmups.** MeCab, Yomitan, JLPT and frequency dictionaries, the optional Jellyfin remote session, Discord presence, AniList token refresh, and the optional AnkiConnect proxy start asynchronously. `startupWarmups` controls which run; its low-power mode defers everything except Yomitan.
+6. **Runtime.** Event-driven. mpv property changes, IPC messages, CLI commands, shortcuts, and config hot-reloads route through handlers and composers. Subtitle text goes through `SubtitleProcessingController` (normalize, tokenize, merge) and out to the overlay renderer and modals.
+7. **Shutdown.** `onWillQuitCleanup` tears down the tray, config watcher, shortcuts, WebSocket and texthooker servers, mpv socket, window tracker, and Yomitan parser window. It flushes the immersion tracker to SQLite and stops Jellyfin, Discord, and the AnkiConnect proxy.
```mermaid
flowchart TB
@@ -386,11 +269,11 @@ flowchart TB
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
```
-## Subtitle prefetch pipeline
+## Subtitle prefetch
-SubMiner can pre-tokenize upcoming subtitle lines before they appear on screen. When an external subtitle file (SRT, VTT, or ASS) is detected on the active track, the `SubtitlePrefetchService` parses all cues via the subtitle cue parser (`subtitle-cue-parser.ts`), identifies a priority window of upcoming lines based on the current playback position, and tokenizes them in the background through the same pipeline used for live subtitles. Results are stored directly into the `SubtitleProcessingController` cache, so when a subtitle actually appears during playback, it hits a warm cache and renders in ~30-50ms instead of ~200-320ms.
+SubMiner tokenizes upcoming subtitle lines before they appear, so they render from a warm cache. `SubtitlePrefetchService` (`src/core/services/subtitle-prefetch.ts`) gets the cue list from the active track: an external subtitle file, or for local media an embedded text track extracted with ffmpeg. It parses the cues with `subtitle-cue-parser.ts`, picks a window of upcoming lines from the playback position, and tokenizes them through the live pipeline, storing results in the `SubtitleProcessingController` cache.
-The prefetcher yields to live subtitle processing (which always takes priority over background work) and re-computes its priority window on seek. Cache invalidation events (e.g. marking a word as known) trigger re-prefetching of the current window to keep results fresh.
+Live subtitle processing always takes priority; the prefetcher pauses while the on-screen line is being processed. It recomputes its window on seek and re-prefetches when the cache is invalidated, for example after a word is marked known.
```mermaid
flowchart TB
@@ -399,7 +282,7 @@ flowchart TB
classDef runtime fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef warmup fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
- SubFile["External Sub File"]:::init
+ SubFile["Subtitle Track"]:::init
Parse["Cue Parser"]:::phase
Window["Upcoming Lines"]:::phase
Tokenize["Pre-tokenize"]:::warmup
@@ -416,22 +299,11 @@ flowchart TB
style Render stroke-width:2px
```
-## Why this design
-
-- **Smaller blast radius:** changing one feature usually touches one service.
-- **Better testability:** most behavior can be tested without Electron windows/mpv.
-- **Better reviewability:** PRs can be scoped to one subsystem.
-- **Backward compatibility:** CLI flags and IPC channels can remain stable while internals evolve.
-- **Runtime registry + domain barrels:** `src/main/runtime/registry.ts` and `src/main/runtime/domains/*` reduce direct fan-in inside `main.ts` while keeping domain ownership explicit.
-- **Extracted composition root:** `main.ts` delegates to focused modules under `src/main/` and `src/main/runtime/composers/` for lifecycle, IPC, overlay, mpv, shortcut, and integration wiring.
-- **Split MPV service layers:** MPV internals are separated into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and properties/render metrics modules for maintainability.
-- **Config by domain:** defaults, option registries, and resolution are split by domain under `src/config/definitions/*` and `src/config/resolve/*`, keeping config evolution localized.
-
## Extension rules
-- Add behavior to an existing service in `src/core/services/*` or create a focused runtime module under `src/main/runtime/*`; avoid ad-hoc logic in `main.ts`.
-- Add new cross-process channels in `src/shared/ipc/contracts.ts` first, validate payloads in `src/shared/ipc/validators.ts`, then wire handlers in IPC runtime modules.
-- See also the contributor IPC onboarding page: [IPC + Runtime Contracts](/ipc-contracts).
-- If change spans startup/overlay/mpv/integration wiring, prefer composing through `src/main/runtime/domains/*` + `src/main/runtime/composers/*` rather than direct wiring in `main.ts`.
-- Keep service APIs explicit and narrowly scoped, and preserve existing CLI flag / IPC channel behavior unless the change is intentionally breaking.
-- Add or update focused tests (including malformed-payload IPC tests) when runtime boundaries or contracts change.
+- Add behavior to a service in `src/core/services/` or a focused module under `src/main/runtime/`. Keep new logic out of `main.ts`.
+- For changes that span startup, overlay, mpv, or integration wiring, compose through `src/main/runtime/domains/` and `src/main/runtime/composers/` instead of wiring directly in `main.ts`.
+- Add a cross-process channel in `src/shared/ipc/contracts.ts` first, validate it in `src/shared/ipc/validators.ts`, then wire the handler. See [IPC + runtime contracts](/ipc-contracts#add-a-new-ipc-action).
+- Config is split by domain under `src/config/definitions/` and `src/config/resolve/`. Keep config changes in the matching domain file.
+- Keep CLI flags and IPC channels stable unless a change is meant to break them.
+- Add or update focused tests when a runtime boundary or contract changes, including malformed-payload tests for IPC.
diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md
index 55b01bf0..c356e81c 100644
--- a/docs-site/character-dictionary.md
+++ b/docs-site/character-dictionary.md
@@ -1,313 +1,104 @@
# Character dictionary
-SubMiner builds a Yomitan-compatible dictionary of a show's characters from [AniList](https://anilist.co), the online anime and manga database. Once it is loaded, character names in subtitles get recognized and highlighted, and hovering one shows the portrait, role, voice actor, and biography without leaving the overlay.
+SubMiner builds a Yomitan dictionary of the characters in the show you are watching, using data from [AniList](https://anilist.co). Character names in subtitles get their own color, and hovering one shows the character's portrait, role, voice actor, and description.
-Proper names rarely appear in ordinary dictionaries, so without this every character name reads as an unknown word. That wrecks N+1 highlighting, since a line naming two characters looks like a line with two unknowns. Recognizing them keeps the highlighting pointed at real vocabulary.
+Ordinary dictionaries rarely contain character names, so without this every name counts as an unknown word and throws off [N+1 highlighting](/subtitle-annotations#n-1-word-highlighting).
-The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes available for hover-driven Yomitan profile lookup.
+## Turning it on
-## How it works
-
-The feature has three stages: **snapshot**, **merge**, and **match**.
-
-1. **Snapshot** - When you start watching a new title, SubMiner queries the AniList GraphQL API for the media's character list. Each character's names, reading, role, description, birthday, voice actors, and portrait are fetched and saved as a local JSON snapshot in `character-dictionaries/snapshots/anilist-{mediaId}.json`. Images are downloaded and base64-encoded into the snapshot.
-
-2. **Merge** - SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP - `character-dictionaries/merged.zip` - which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds.
-
-3. **Match** - During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
-
-## Enabling the feature
-
-Character dictionary sync is disabled by default. To turn it on:
-
-1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
-2. Start watching. SubMiner queries AniList's public GraphQL API, which needs no authentication, and imports the merged dictionary into Yomitan.
-3. Optionally enable **Name Match Images** (Settings → Subtitle Style) to show inline circular character portraits next to matched names in subtitles.
+1. Set `subtitleStyle.nameMatchEnabled` to `true`, or turn it on in the Settings window under Annotation Display, Character Names.
+2. Optionally set `subtitleStyle.nameMatchImagesEnabled` to `true` to show a small portrait next to each name in the subtitle line.
+3. Play an episode.
```jsonc
{
"subtitleStyle": {
"nameMatchEnabled": true,
- "nameMatchImagesEnabled": true, // optional - inline portraits
+ "nameMatchImagesEnabled": true,
},
}
```
-::: 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.
-:::
+No AniList account is needed. Logging in to AniList is only for [watch progress sync](/anilist-integration).
-::: info
-AniList character data is fetched via public GraphQL queries - no account or access token is needed. AniList authentication is only required for the separate [watch-progress sync](/anilist-integration) feature.
-:::
+The character dictionary does not work when `yomitan.externalProfilePath` is set, because SubMiner then uses another app's Yomitan profile read-only.
-::: warning
-If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
-:::
+## What happens when you play something
-## Name generation
+When a new show starts, SubMiner:
-A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
+1. Guesses the title from the filename and finds it on AniList.
+2. Downloads the cast list and portraits.
+3. Builds the dictionary and imports it into SubMiner's Yomitan.
-**Spacing and combination:**
+A notification shows each step. Once it says the dictionary is ready, names match from the next subtitle line.
-- Full name with space: 須々木 心一
-- Combined form: 須々木心一
-- Family name alone: 須々木
-- Given name alone: 心一
+Each character gets entries for the full name, family name, given name, and common honorifics (`さん`, `君`, `ちゃん`, `先生`, and others), so `太郎さん` matches as well as `太郎`.
-Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary. That guess can be ambiguous, since 東紫乃 could be 東+紫乃 or 東紫+乃, so SubMiner generates terms for the top two candidate boundaries and the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
+SubMiner keeps your most recent shows loaded in one merged dictionary. `anilist.characterDictionary.maxLoaded` sets how many. Starting another show drops the oldest one. Only the current show's characters are highlighted.
-**Middle-dot removal** (common in katakana foreign names):
+### How long it takes
-- ア・リ・ス → アリス (combined), plus individual segments
-
-**Honorific suffixes** - each base name is expanded with 15 common suffixes:
-
-| Honorific | Reading |
-| --------- | ---------- |
-| さん | さん |
-| 様 | さま |
-| 先生 | せんせい |
-| 先輩 | せんぱい |
-| 後輩 | こうはい |
-| 氏 | し |
-| 君 | くん |
-| くん | くん |
-| ちゃん | ちゃん |
-| たん | たん |
-| 坊 | ぼう |
-| 殿 | どの |
-| 博士 | はかせ |
-| 社長 | しゃちょう |
-| 部長 | ぶちょう |
-
-**Romanized names** - names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text.
-
-This means a character like "太郎" generates entries for 太郎, 太郎さん, 太郎先生, 太郎君, 太郎ちゃん, and so on - all with correct readings.
-
-## Name matching
-
-Name matching runs inside Yomitan's scanning pipeline during subtitle tokenization.
-
-1. Yomitan receives subtitle text and scans for dictionary matches.
-2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching - the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
-3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits.
-4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
-5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
-6. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
-
-Older snapshot schema versions are regenerated automatically. Current-version snapshots are normally reused, but when `subtitleStyle.nameMatchImagesEnabled` is enabled SubMiner also checks whether the cached snapshot contains usable character portrait data. If it does not, the snapshot is refreshed so the merged dictionary can include images.
-
-Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
-
-**Key settings:**
-
-| Option | Default | Description |
-| -------------------------------------- | --------- | ----------------------------------------- |
-| `subtitleStyle.nameMatchEnabled` | `false` | Enable dictionary sync and highlighting |
-| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
-| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
-
-## Inline character portraits
-
-When `subtitleStyle.nameMatchImagesEnabled` is enabled, SubMiner injects a small circular portrait image directly into the subtitle line next to each matched character name.
-
-Portraits are sourced from the local snapshot - they are embedded at snapshot-generation time and served from the cached ZIP, so no network request happens during playback. Images are downloaded from AniList CDN once per character and stored in `character-dictionaries/img/`.
-
-If a snapshot was generated before portrait data was available (e.g. during an earlier version or offline sync), SubMiner detects the missing image data on the next media match and automatically refreshes the snapshot so portraits are included in the next merged dictionary build.
-
-**To enable:**
-
-- Settings → Subtitle Style → **Name Match Images**, or
-- `subtitleStyle.nameMatchImagesEnabled: true` in config.
-
-The portrait size is controlled by the surrounding subtitle font size and renders as a circle clipped from the character's AniList cover image.
-
-::: tip
-Inline portraits help you quickly associate names with faces while building vocabulary - especially useful for shows with large casts where you're still learning who's who.
-:::
-
-## Dictionary entries
-
-Each character entry in the Yomitan dictionary includes structured content:
-
-- **Name** - the matched Japanese name form
-- **Known names** - generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
-- **Role badge** - color-coded by role: main / "Protagonist" (score 100), primary / "Main Character" (75), side / "Side Character" (50), appears / "Minor Role" (25). AniList's MAIN maps to main, SUPPORTING to primary, and BACKGROUND to side.
-- **Portrait** - character image from AniList, embedded in the ZIP
-- **Description** - biography text from AniList (collapsible)
-- **Character information** - age, birthday, gender, blood type (collapsible)
-- **Voiced by** - voice actor name and portrait (collapsible)
-
-The three collapsible sections can be configured to start open or closed:
-
-```jsonc
-{
- "anilist": {
- "characterDictionary": {
- "collapsibleSections": {
- "description": false,
- "characterInformation": false,
- "voicedBy": false,
- },
- },
- },
-}
-```
-
-## Auto-sync lifecycle
-
-When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
-
-These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import.
-
-**Phases:**
-
-1. **checking** - Is there already a cached snapshot for this media ID?
-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).
-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.
-
-```jsonc
-{
- "activeMediaIds": ["170942 - Frieren", "163134 - ...", "154587 - ..."],
- "mergedRevision": "a1b2c3d4e5f6",
- "mergedDictionaryTitle": "SubMiner Character Dictionary",
-}
-```
-
-(Entries are `" - "` label strings; bare numeric IDs from older versions are still read.)
-
-The `maxLoaded` setting (default: 3) controls how many media snapshots stay in the active set. When you start a 4th title, the oldest is evicted and the merged dictionary is rebuilt without it.
-
-## Manual generation
-
-You can generate a character dictionary from the command line without auto-sync:
-
-```bash
-# Generate for a file or directory
-subminer dictionary /path/to/media
-
-# Generate for current anime (AppImage)
-SubMiner.AppImage --dictionary
-```
-
-This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
+The first time you watch a show, most of the time goes into downloading portraits, one at a time. A typical cast takes seconds to a minute. A very large cast takes much longer: One Piece has over a thousand characters and takes around 10 minutes. After that the show is cached, and later episodes load it from disk.
## Correcting AniList matches
-SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
+SubMiner can match the wrong show when a filename is ambiguous, for example `Re - ZERO, Starting Life in Another World (2016)` matching a different `Re...` series. To fix it:
-Use the in-app selector or CLI to pin the correct AniList media for the whole series:
+1. Press `Ctrl/Cmd+D` to open the character dictionary manager.
+2. Click **Override**, edit the title if needed, search, and pick the right result.
-- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result.
-- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
+From the command line:
```bash
-# List candidate AniList matches for a file
+# List AniList matches for a file
subminer dictionary --candidates "/path/to/episode.mkv"
-# Save the correct AniList media ID for that series
+# Save the correct AniList ID for that series
subminer dictionary --select 21355 "/path/to/episode.mkv"
-
-# Equivalent direct app flags
-SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv"
-SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
-
-# Open the in-app selector from the running app
-subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
```
-SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override - including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
+The override applies to every episode of that season in the same folder. Other seasons are not affected, even when they share the folder. The override also sets which entry [AniList watch progress](/anilist-integration) updates, so one fix covers both.
-An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
+## Managing loaded shows
-## Managing loaded entries
+The manager (`Ctrl/Cmd+D`) lists the shows in the merged dictionary and marks the current one.
-Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
+- **Remove** drops a show from the dictionary. You cannot remove the show you are watching.
+- **Up/Down** changes which show gets dropped first when a new one is added.
+- **Override** replaces a show's AniList match.
-- **Remove** drops a non-current entry from the active merged dictionary and rebuilds/imports once.
-- **Up/Down** changes MRU order for future eviction; the merged dictionary is rebuilt and re-imported after a reorder.
-- **Override** opens the AniList selector for that entry's title so you can replace a saved loaded entry.
+## Generating from the command line
-The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
-
-## File structure
-
-All character dictionary data lives under `{userData}/character-dictionaries/`:
-
-```text
-character-dictionaries/
- snapshots/
- anilist-170942.json # Per-media character snapshot
- anilist-163134.json
- merged.zip # Active merged dictionary (imported into Yomitan)
- auto-sync-state.json # Tracks active media IDs and revision
- anilist-overrides.json # Manual series-to-AniList overrides
- img/
- m170942-c12345.jpg # Character portrait
- m170942-va67890.jpg # Voice actor portrait
+```bash
+subminer dictionary /path/to/media
```
-**Snapshot format** (v19, `CHARACTER_DICTIONARY_FORMAT_VERSION`): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images. Snapshots with a different format version are regenerated.
+This builds a standalone dictionary file for that file or folder without playing it. With the AppImage directly, use `SubMiner.AppImage --dictionary`.
-**ZIP structure** follows the Yomitan dictionary format:
+## Configuration
-```text
-merged.zip
- index.json # { title, revision, format: 3, author: "SubMiner", description }
- tag_bank_1.json # Tag definitions
- term_bank_1.json # Up to 10,000 terms per bank
- term_bank_2.json
- img/ # Embedded character and VA portraits
-```
+Defaults are in the [configuration reference](/configuration).
-## Configuration reference
-
-| Option | Default | Description |
-| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
-| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
-| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
-| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
-| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
-| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
-| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-dictionary sync and name highlighting |
-| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
-| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
-
-## Reference implementation
-
-SubMiner's character dictionary builder is inspired by the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project - a standalone Rust web service that generates Yomitan character dictionaries from AniList and VNDB data.
-
-The reference implementation covers the same ground: name variant generation, honorific expansion, structured Yomitan content, and portrait embedding. It also reads VNDB as a source for visual novel characters. Key differences:
-
-| | SubMiner | Reference Implementation |
-| ---------------------- | -------------------------------------------- | ------------------------------------- |
-| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
-| **Data sources** | AniList only | AniList + VNDB |
-| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
-| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export |
-| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) |
-| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh |
-
-If you work with visual novels or want a standalone dictionary generator independent of SubMiner, the reference implementation is worth checking out.
+| Key | What it does |
+| ---------------------------------------------------------------------- | ------------------------------------------------ |
+| `subtitleStyle.nameMatchEnabled` | Build the dictionary and color character names |
+| `subtitleStyle.nameMatchImagesEnabled` | Show a portrait next to matched names |
+| `subtitleStyle.nameMatchColor` | Color for character names |
+| `anilist.characterDictionary.maxLoaded` | Number of recent shows kept in the dictionary |
+| `anilist.characterDictionary.collapsibleSections.description` | Show the description expanded in the popup |
+| `anilist.characterDictionary.collapsibleSections.characterInformation` | Show age, birthday, and similar details expanded |
+| `anilist.characterDictionary.collapsibleSections.voicedBy` | Show the voice actor section expanded |
+| `shortcuts.openCharacterDictionaryManager` | Shortcut for the manager |
## Troubleshooting
-- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry - SubMiner needs a media ID to fetch characters.
-- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed.
-- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
-- **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id `. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known.
-- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
-- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
+**It seems stuck.** Check the notification. While generating it shows counts (`image 120/400`), an estimate of time left, and an elapsed clock. If the clock moves, it is still working, and large casts are slow (see [how long it takes](#how-long-it-takes)). If you missed the notification, open the notification history with `Ctrl/Cmd+N`. For errors, check the app log ([log locations](/troubleshooting)).
-## Related
+**Import failed or timed out.** Yomitan may have been busy importing another dictionary. Play the next episode or restart SubMiner. The import may have finished anyway, so check for the character popup before retrying.
-- [Subtitle Annotations](/subtitle-annotations) - how name matches interact with N+1, frequency, and JLPT layers
-- [AniList Integration](/anilist-integration) - watch-progress sync and AniList authentication (separate from character dictionary)
-- [Configuration Reference](/configuration) - full config options
+**Names are not highlighted.** Check that `subtitleStyle.nameMatchEnabled` is `true`, that `yomitan.externalProfilePath` is empty, and that the show was found on AniList. The wrong show's cast means a wrong match. See [correcting AniList matches](#correcting-anilist-matches).
+
+**Portraits are missing.** Portraits need AniList to have an image and the download to succeed. If you were offline during the first sync, delete that show's file from `character-dictionaries/snapshots/` in the SubMiner config directory and replay it.
+
+SubMiner's generator is based on the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project, which also supports VNDB and works without SubMiner.
diff --git a/docs-site/configuration.md b/docs-site/configuration.md
index 847058af..b3de3142 100644
--- a/docs-site/configuration.md
+++ b/docs-site/configuration.md
@@ -8,76 +8,24 @@ outline: [2, 3]
import { withBase } from 'vitepress';
-One file, `config.jsonc`, holds everything. Most of it is also editable from the in-app **Settings** window, so hand-editing is rarely necessary.
+All SubMiner settings live in one file, `config.jsonc`. Most of them are also editable in the Settings window, so you rarely need to edit the file by hand. This page lists every config block with its keys and defaults.
-This page is the full reference. It covers the Settings window, where the config file lives, and every option grouped by topic. If you are just starting out, the Quick Start below and the [Settings window](#settings) are enough.
+## Config file {#configuration-file}
-## Quick start
+| Platform | Path |
+| ------------ | ------------------------------------------------------------------------------------- |
+| Linux, macOS | `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (`~/.config/SubMiner/config.jsonc` if unset) |
+| Windows | `%APPDATA%\SubMiner\config.jsonc` |
-Start here:
+The file is JSONC, so comments and trailing commas are allowed. If both `config.jsonc` and `config.json` exist, SubMiner uses `config.jsonc`. Only add the keys you want to change. Everything else uses the built-in default.
-```json
-{
- "ankiConnect": {
- "enabled": true,
- "deck": "YourDeckName",
- "knownWords": {
- "decks": {
- "YourDeckName": ["Word"]
- }
- },
- "fields": {
- "sentence": "Sentence",
- "audio": "Audio",
- "image": "Image"
- }
- }
-}
-```
+The [generated example config](/config.example.jsonc) lists every option with its default and a comment. Defaults in the tables below come from that file.
-Use the known-word deck map to choose which Anki decks and note fields feed the known-word cache.
-
-Everything else is optional; the sections below cover it.
-
-## Settings
-
-Open the **Settings** window from the tray menu, the app's `--settings` flag, or `subminer settings`. It writes straight to `config.jsonc`, so anything you change there is a normal config edit you can inspect afterward.
-
-The Settings window groups options by workflow instead of mirroring the raw config-file shape:
-
-- Appearance
-- Behavior
-- Mining & Anki
-- Input
-- Integrations
-- Tracking & App
-- Advanced
-
-Playback-related fields live as sections inside these groups (for example "Playback Behavior" under **Behavior** and "mpv Playback" / "YouTube Playback Settings" under **Integrations**).
-
-Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes.
-
-The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
-
-Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available.
-
-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
-
-The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
-
-Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
-On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
-When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
-
-See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
-
-::: warning One value in that file is platform-specific
-The example is generated with a fixed Linux/macOS socket path so it stays reproducible, so it shows `"socketPath": "/tmp/subminer-socket"`. On Windows the real default is `\\\\.\\pipe\\subminer-socket`. Leave `mpv.socketPath` out of your config entirely unless you need a custom path, and SubMiner picks the right one for your platform.
+::: warning mpv.socketPath differs on Windows
+The example shows `"socketPath": "/tmp/subminer-socket"`. On Windows the default is `\\.\pipe\subminer-socket`. Leave `mpv.socketPath` out of your config unless you need a custom path, and SubMiner picks the right one.
:::
-Generate a fresh default config from the centralized config registry:
+To write a fresh default config:
```bash
SubMiner.AppImage --generate-config
@@ -85,1029 +33,391 @@ SubMiner.AppImage --generate-config --config-path /tmp/subminer.jsonc
SubMiner.AppImage --generate-config --backup-overwrite
```
-- `--generate-config` writes a default JSONC config template.
-- JSONC config supports comments and trailing commas.
-- If the target file exists, SubMiner prompts to create a timestamped backup and overwrite.
-- In non-interactive shells, use `--backup-overwrite` to explicitly back up and overwrite.
-- On Windows, generated configs default to `%APPDATA%\SubMiner\config.jsonc`.
+If the target file exists, SubMiner asks before backing it up and overwriting it. In non-interactive shells, pass `--backup-overwrite`.
-Malformed config syntax (invalid JSON/JSONC) is startup-blocking: SubMiner shows a clear parse error with the config path and asks you to fix the file and restart.
+A syntax error in the file stops startup with a message that names the file. A valid file with a bad value logs a warning and uses the default for that key. On macOS, these warnings also open a dialog.
-For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback behavior: it logs the bad key/value and continues with the default for that option.
+## Settings window {#settings}
-On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
+Open it from the tray menu, with `subminer settings`, or with the app's `--settings` flag. Options are grouped by task (Appearance, Behavior, Mining & Anki, Input, Integrations, Tracking & App, Advanced) rather than by config block, but each field saves to its normal `config.jsonc` path.
-### Hot-reload behavior
+- Saving keeps your comments, trailing commas, and unrelated keys. Resetting a field removes its key so the default applies.
+- Each field is tagged **Live** or **Restart**. After saving, a banner lists any sections that need a restart.
+- Anki fields can fetch deck, note type, and field names from AnkiConnect.
+- Secret fields never show the stored value, only whether one is set. Prefer the `*Command` variants (such as `jimaku.apiKeyCommand`) to keep keys out of the file.
-SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
+## Hot-reload {#hot-reload-behavior}
-Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
-shortcuts, notifications, logging level, selected source-language preferences,
-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.
+SubMiner watches the config file while running. When it changes, live settings apply immediately and SubMiner shows a notification listing any changed sections that need a restart. If the new file is invalid, the previous config stays active.
-When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
+These apply live:
-Restart-required changes:
+- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
+- `logging.level`, `logging.rotation`, `logging.files`
+- `secondarySub.defaultMode`, `youtube.primarySubLanguages`
+- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
+- `ankiConnect.deck`, `ankiConnect.fields.*`, `ankiConnect.behavior.autoUpdateNewCards`
+- `ankiConnect.media.normalizeAudio`, `media.mirrorMpvVolume`, `media.reviewTiming`
+- `ankiConnect.knownWords` (`highlightEnabled`, `refreshMinutes`, `addMinedWordsImmediately`, `matchMode`, `decks`) and `ankiConnect.nPlusOne.*`
+- `ankiConnect.isLapis.sentenceCardModel`, `isKiku.fieldGrouping`, `isSenren.fieldGrouping`, `lapisKiku.wordCardKind`
-- Any other config sections still require restart.
-- AnkiConnect transport/proxy/media/tag fields still require restart unless listed above.
-- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
+These are read at the start of the next operation, so changes take effect on the next request or run: `jimaku`, `tmdb`, `subsync`, `subtitleGeneration`, `notifications`.
-### Configuration options Overview
-
-The configuration file includes several main sections:
-
-**Core Settings**
-
-- [**Logging**](#logging) - Runtime log level
-- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
-- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
-- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
-- [**Annotation WebSocket**](#annotation-websocket) - Dedicated annotated subtitle payload stream
-- [**Texthooker**](#texthooker) - Control browser opening behavior
-
-**Subtitle Display**
-
-- [**Subtitle Style**](#subtitle-style) - Appearance customization
-- [**Subtitle Sidebar**](#subtitle-sidebar) - Parsed cue list sidebar modal
-- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
-- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
-
-**Keyboard & Controls**
-
-- [**Keybindings**](#keybindings) - MPV command shortcuts
-- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
-- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode
-- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
-- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
-- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
-
-**Anki Integration**
-
-- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
-- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis/Senren note types
-- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
-- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Senren duplicate card merging
-
-**External Integrations**
-
-- [**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
-- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
-- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
-- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
-- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
-- [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
-- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
-- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
-- [**Notifications**](#notifications) - Overlay notification placement
+Everything else needs a restart.
## Core settings
### Logging
-Control the minimum log level for runtime output:
+Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone.
-```json
-{
- "logging": {
- "level": "warn",
- "rotation": 7,
- "files": {
- "app": true,
- "launcher": true,
- "mpv": false
- }
- }
-}
-```
-
-| Option | Values | Description |
-| ---------------- | ---------------------------------------- | -------------------------------------------------------------------- |
-| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"warn"`) |
-| `rotation` | positive integer | Number of days of app, launcher, and mpv logs to retain (default: 7) |
-| `files.app` | boolean | Write SubMiner app runtime logs (default: `true`) |
-| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
-| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
-
-Log filenames use the local calendar date, for example `app-YYYY-MM-DD.log`, `launcher-YYYY-MM-DD.log`, and `mpv-YYYY-MM-DD.log`.
-Log export creates a sanitized copy of those files; it does not rewrite the original log files on disk.
+| Key | Default | What it does |
+| ------------------------ | -------- | ------------------------------------------------------- |
+| `logging.level` | `"warn"` | Minimum level: `debug`, `info`, `warn`, `error` |
+| `logging.rotation` | `7` | Days of logs to keep |
+| `logging.files.app` | `true` | Write app logs |
+| `logging.files.launcher` | `true` | Write launcher logs |
+| `logging.files.mpv` | `false` | Write mpv logs. Turn on temporarily to debug mpv/plugin |
### Updates
-Configure automatic update checks and update notifications:
+Manual checks from the tray or `subminer -u` always work, even with automatic checks off. Overlay update notifications include an **Update** button.
-```json
-{
- "updates": {
- "enabled": true,
- "checkIntervalHours": 24,
- "notificationType": "overlay",
- "channel": "stable"
- }
-}
-```
-
-| Option | Values | Description |
-| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
-| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
-| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
-| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
-| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
-
-When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
-
-`osd` and `osd-system` are legacy config-file-only notification values. The Settings window offers `overlay`, `system`, `both`, and `none`; if your config already contains `osd` or `osd-system`, it is shown as the selected value but not offered as a normal choice. If you previously used `both` for mpv OSD + system notifications, set `notificationType` to `"osd-system"` in `config.jsonc` to keep that behavior.
+| Key | Default | What it does |
+| ---------------------------- | ----------- | --------------------------------------------------------- |
+| `updates.enabled` | `true` | Check for updates in the background |
+| `updates.checkIntervalHours` | `24` | Minimum hours between automatic checks |
+| `updates.notificationType` | `"overlay"` | `overlay`, `system`, `both` (overlay + system), or `none` |
+| `updates.channel` | `"stable"` | `stable` or `prerelease` (betas and release candidates) |
### Notifications
-Configure where overlay notification cards appear:
+Overlay notifications are also kept in a session-only history panel. Toggle it with `shortcuts.toggleNotificationHistory`. The panel opens from the same side as the notification cards.
-```json
-{
- "notifications": {
- "overlayPosition": "top-right"
- }
-}
-```
+| Key | Default | What it does |
+| ------------------------------- | ------------- | ---------------------------------------------------------- |
+| `notifications.overlayPosition` | `"top-right"` | Where overlay cards appear: `top-left`, `top`, `top-right` |
-| Option | Values | Description |
-| ----------------- | ---------------------------------------- | ------------------------------------------------------------------ |
-| `overlayPosition` | `"top-left"` \| `"top"` \| `"top-right"` | Position for in-overlay notification cards. Default `"top-right"`. |
-
-#### Notification history panel
-
-Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use, so left when `overlayPosition` is `"top-left"` and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
-
-Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
-
-The equivalent direct CLI command is `--playback-feedback ` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config.
+Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
### Auto-start overlay
-Control whether the overlay automatically becomes visible when it connects to mpv:
+When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
-```json
-{
- "auto_start_overlay": true
-}
-```
-
-| Option | Values | Description |
-| -------------------- | --------------- | ----------------------------------------------------- |
-| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
-
-When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness.
-
-On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
+| Key | Default | What it does |
+| -------------------- | ------- | ------------------------------------------------------------ |
+| `auto_start_overlay` | `true` | Show the visible overlay when the mpv plugin starts SubMiner |
### Startup warmups
-Control which startup warmups run in the background versus deferring to first real usage:
+Warmups load components in the background at startup. Turn one off to load it on first use instead.
-```json
-{
- "startupWarmups": {
- "lowPowerMode": false,
- "mecab": true,
- "yomitanExtension": true,
- "subtitleDictionaries": true,
- "jellyfinRemoteSession": false
- }
-}
-```
-
-| Option | Values | Description |
-| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
-| `lowPowerMode` | `true`, `false` | Defer all warmups except Yomitan extension |
-| `mecab` | `true`, `false` | Warm up MeCab tokenizer at startup |
-| `yomitanExtension` | `true`, `false` | Warm up Yomitan extension at startup |
-| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
-| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
-
-Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExtension`, and `subtitleDictionaries`) with `lowPowerMode: false`; Jellyfin remote session warmup is opt-in (`false` by default). Setting a warmup toggle to `false` defers that work until first usage.
+| Key | Default | What it does |
+| -------------------------------------- | ------- | ----------------------------------------------------------------------------- |
+| `startupWarmups.lowPowerMode` | `false` | Defer every warmup except the Yomitan extension |
+| `startupWarmups.mecab` | `true` | Load the MeCab tokenizer |
+| `startupWarmups.yomitanExtension` | `true` | Load the Yomitan extension |
+| `startupWarmups.subtitleDictionaries` | `true` | Load the JLPT and frequency dictionaries |
+| `startupWarmups.jellyfinRemoteSession` | `false` | Connect the Jellyfin remote session (also needs Jellyfin remote auto-connect) |
### WebSocket server
-The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
+Broadcasts plain subtitle text to external clients. See [WebSocket / Texthooker API](/websocket-texthooker-api) for payloads and client examples.
-For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
-
-By default, the server is disabled. Set `enabled` to `true` to force it on, or `"auto"` to start it unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`.
-
-See `config.example.jsonc` for detailed configuration options.
-
-```json
-{
- "websocket": {
- "enabled": false,
- "port": 6677
- }
-}
-```
-
-| Option | Values | Description |
-| ------------------- | ------------------------- | --------------------------------------------------- |
-| `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
-| `websocket.port` | number | WebSocket server port (default: 6677) |
+| Key | Default | What it does |
+| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| `websocket.enabled` | `false` | `true`, `false`, or `"auto"` (start unless the [mpv_websocket](https://github.com/kuroahna/mpv_websocket) plugin is installed) |
+| `websocket.port` | `6677` | Server port |
### Annotation WebSocket
-SubMiner also exposes a dedicated annotated websocket stream for the bundled texthooker UI and token-aware clients.
+A separate stream that adds token data (known word, N+1, frequency, JLPT, character names) to each subtitle. The bundled texthooker uses it.
-This stream includes subtitle text plus token metadata (N+1, known-word, frequency, JLPT, and character-name annotation context).
-
-```json
-{
- "annotationWebsocket": {
- "enabled": false,
- "port": 6678
- }
-}
-```
-
-| Option | Values | Description |
-| ----------------------------- | --------------- | -------------------------------------------------------------- |
-| `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
-| `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) |
+| Key | Default | What it does |
+| ----------------------------- | ------- | ------------------------------------------------------- |
+| `annotationWebsocket.enabled` | `false` | Start the annotated stream (independent of `websocket`) |
+| `annotationWebsocket.port` | `6678` | Server port |
### Texthooker
-Control whether texthooker starts automatically and whether it opens a browser:
-
-See `config.example.jsonc` for detailed configuration options.
-
-```json
-{
- "texthooker": {
- "launchAtStartup": false,
- "openBrowser": false
- }
-}
-```
-
-| Option | Values | Description |
-| ----------------- | --------------- | ----------------------------------------------------------------------- |
-| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `false`) |
-| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
+| Key | Default | What it does |
+| ---------------------------- | ------- | ------------------------------------------------------- |
+| `texthooker.launchAtStartup` | `false` | Start the texthooker server when SubMiner starts |
+| `texthooker.openBrowser` | `false` | Open the texthooker page in your browser when it starts |
## Subtitle display
### Subtitle style
-Customize the appearance of primary and secondary subtitles:
+Controls how primary and secondary subtitles look and which annotations they show. `css` and `secondary.css` take CSS declarations with normal property names. See [Subtitle annotations](/subtitle-annotations) for how known-word, N+1, frequency, JLPT, and character-name highlighting work.
-See `config.example.jsonc` for detailed configuration options.
-
-```json
+```jsonc
{
"subtitleStyle": {
- "css": {
- "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
- "color": "#cad3f5",
- "background-color": "transparent",
- "font-size": "35px",
- "font-weight": "600",
- "line-height": "1.35",
- "letter-spacing": "-0.01em",
- "word-spacing": "0",
- "font-kerning": "normal",
- "text-rendering": "geometricPrecision",
- "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)",
- "font-style": "normal",
- "backdrop-filter": "blur(6px)",
- "--subtitle-hover-token-color": "#f4dbd6",
- "--subtitle-hover-token-background-color": "transparent"
- },
- "secondary": {
- "css": {
- "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
- "color": "#cad3f5",
- "background-color": "transparent",
- "font-size": "24px",
- "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)"
- }
- }
- }
+ "css": { "font-size": "40px", "color": "#ffffff" },
+ "secondary": { "css": { "font-size": "24px" } },
+ },
}
```
-| Option | Values | Description |
-| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
-| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
-| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
-| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
-| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
-| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
-| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
-| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
-| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
-| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
-| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
-| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
-| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
-| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
-| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
-| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
-| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
-| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
-| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
-| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
-| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
-| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
+| Key | Default | What it does |
+| ------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------- |
+| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
+| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
+| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
+| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
+| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
+| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
+| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
+| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
+| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
+| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
+| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
+| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
+| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
+| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
+| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
+| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
+| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
+| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
+| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
+| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
+| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
+| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
-Subtitle CSS custom properties:
-
-| CSS Property | Default | Description |
-| ----------------------------------------- | ------------- | --------------------------------------- |
-| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color |
-| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color |
-
-The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
-the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example
-uses that same CSS declaration shape.
-
-Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
-
-Lookup behavior:
-
-- Point the source path at a directory containing `term_meta_bank_*.json` for a fully custom source.
-- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
-- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
-- Match mode controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
-- Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist.
-
-In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
-
-Character-name highlighting is separate from N+1 and frequency highlighting:
-
-- `nameMatchEnabled` controls whether SubMiner syncs the character dictionary and includes character-dictionary name matches in subtitle token metadata and renderer styling.
-- `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots.
-- `nameMatchColor` sets the highlight color for those matched character names.
-- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when name matching is enabled.
-
-Secondary subtitle styling lives in the secondary subtitle CSS object. Any CSS property not set there falls back to the secondary subtitle defaults, then the normal renderer defaults.
-
-**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
+Two CSS custom properties style the hovered word: `--subtitle-hover-token-color` (`#f4dbd6`) and `--subtitle-hover-token-background-color` (`transparent`). Set them inside `subtitleStyle.css`.
### Subtitle sidebar
-Configure the parsed-subtitle sidebar modal.
+A scrollable cue list for the current subtitle file. It only works when SubMiner could parse the active subtitle into cues. See [Subtitle sidebar](/subtitle-sidebar).
-```json
-{
- "subtitleSidebar": {
- "enabled": true,
- "autoOpen": false,
- "layout": "overlay",
- "toggleKey": "Backslash",
- "pauseVideoOnHover": true,
- "autoScroll": true,
- "css": {
- "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
- "font-size": "16px",
- "color": "#cad3f5",
- "background-color": "rgba(73, 77, 100, 0.9)",
- "--subtitle-sidebar-max-width": "420px"
- }
- }
-}
-```
+| Key | Default | What it does |
+| ----------------------------------- | ------------- | ------------------------------------------------------------------------------ |
+| `subtitleSidebar.enabled` | `true` | Enable the sidebar |
+| `subtitleSidebar.autoOpen` | `false` | Open it once when the overlay starts |
+| `subtitleSidebar.layout` | `"overlay"` | `overlay` floats over mpv. `embedded` reserves space on the right of the video |
+| `subtitleSidebar.toggleKey` | `"Backslash"` | `KeyboardEvent.code` that opens and closes it |
+| `subtitleSidebar.pauseVideoOnHover` | `true` | Pause while hovering the cue list |
+| `subtitleSidebar.autoScroll` | `true` | Keep the active cue in view |
+| `subtitleSidebar.css` | see example | CSS for the sidebar, plus the custom properties below |
-| Option | Values | Description |
-| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
-| `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
-| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
-| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
-| `subtitleSidebar.toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
-| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
-| `autoScroll` | boolean | Keep the active cue in view while playback advances |
-| `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. |
+Sidebar custom properties: `--subtitle-sidebar-max-width` (`420px`), `--subtitle-sidebar-timestamp-color`, `--subtitle-sidebar-active-line-color`, `--subtitle-sidebar-active-background-color`, `--subtitle-sidebar-hover-background-color`. Their defaults are in the example config.
-Direct style keys are also available under `subtitleSidebar` and map to the same visuals as the CSS custom properties: `maxWidth` (default `420`), `opacity` (`0.95`), `backgroundColor`, `textColor`, `fontFamily`, `fontSize` (`16`), `timestampColor`, `activeLineColor`, `activeLineBackgroundColor`, and `hoverLineBackgroundColor`.
-
-Sidebar CSS custom properties:
-
-| CSS Property | Default | Description |
-| -------------------------------------------- | --------------------------- | ---------------------------- |
-| `--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 |
-
-The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
-
-`embedded` layout is intended to act like a split-pane view: it reserves player space with a right-side video margin and keeps interaction in both the player area and sidebar. If you see unexpected offset behavior in your environment, switch back to `overlay` to isolate sidebar placement.
-
-For full details on layout modes, behavior, and the keyboard shortcut, see the [Subtitle Sidebar](/subtitle-sidebar) page.
-
-`subtitleStyle.jlptColors` keys are:
-
-| Key | Default | Description |
-| ---- | --------- | ----------------------- |
-| `N1` | `#ed8796` | JLPT N1 underline color |
-| `N2` | `#f5a97f` | JLPT N2 underline color |
-| `N3` | `#f9e2af` | JLPT N3 underline color |
-| `N4` | `#8bd5ca` | JLPT N4 underline color |
-| `N5` | `#8aadf4` | JLPT N5 underline color |
+If `embedded` layout places the video oddly on your system, switch back to `overlay`.
### Subtitle position
-Set the initial vertical subtitle position (measured from the bottom of the screen):
+You can also drag subtitles with `Right-click + drag` while watching.
-```json
-{
- "subtitlePosition": {
- "yPercent": 10
- }
-}
-```
-
-| Option | Values | Description |
-| ---------- | ---------------- | ---------------------------------------------------------------------- |
-| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
-
-In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
+| Key | Default | What it does |
+| --------------------------- | ------- | ---------------------------------------------------------------- |
+| `subtitlePosition.yPercent` | `10` | Starting distance from the bottom, as a percent of screen height |
### Secondary subtitles
-Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
+Shows a second track, such as English, above the Japanese line.
-See `config.example.jsonc` for detailed configuration options.
-
-Secondary subtitles do **not** auto-load by default. To turn them on for local and Jellyfin playback, set `autoLoadSecondarySub` to `true` and list the language codes you want:
+Secondary subtitles do **not** auto-load by default (`autoLoadSecondarySub`, default: `false`). To load them for local and Jellyfin playback, turn it on and list the languages you want:
```json
{
"secondarySub": {
"secondarySubLanguages": ["eng", "en"],
- "autoLoadSecondarySub": true,
- "defaultMode": "hover"
+ "autoLoadSecondarySub": true
}
}
```
-| Option | Values | Description |
-| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
-| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
-| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
-| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
+| Key | Default | What it does |
+| ------------------------------------ | --------- | --------------------------------------------------------------------------------------- |
+| `secondarySub.secondarySubLanguages` | `[]` | Language codes in priority order. Regular tracks win over Signs/Songs tracks |
+| `secondarySub.autoLoadSecondarySub` | `false` | Load a matching secondary track when the primary loads |
+| `secondarySub.defaultMode` | `"hover"` | `hidden`, `visible` (always shown), or `hover` (shown when you hover the subtitle area) |
-These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
+YouTube ignores the first two keys and always picks English. See [YouTube integration](/youtube-integration). `defaultMode` applies everywhere.
-The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
+### Subtitle selection {#subtitle-selection}
-**Display modes:**
+Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it with `g` then `s` (`shortcuts.openSubtitleSelection`). While enabled, that shortcut replaces mpv's own binding for the same key. See [Keyboard shortcuts](/shortcuts) for sequence conflicts.
-- **hidden** - Secondary subtitles not shown
-- **visible** - Always visible at top of overlay
-- **hover** - Only visible when hovering over the subtitle area (default)
-
-**See `config.example.jsonc`** for additional secondary subtitle configuration options.
+| Key | Default | What it does |
+| --------------------------- | ------- | ----------------------------------- |
+| `subtitleSelection.enabled` | `false` | Enable the subtitle selection modal |
## Keyboard and controls
### Keybindings
-Add a `keybindings` array to configure keyboard shortcuts that send mpv commands or SubMiner session actions:
-
-See `config.example.jsonc` for detailed configuration options and more examples.
-
-**Default keybindings:**
-
-| Key | Command | Description |
-| ----------------------- | ----------------------------- | --------------------------------------- |
-| `Space` | `["cycle", "pause"]` | Toggle pause |
-| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
-| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
-| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
-| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
-| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
-| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
-| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
-| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
-| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
-| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
-| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
-| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
-| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
-| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
-| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
-| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
-| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
-| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
-| `KeyQ` | `["quit"]` | Quit mpv |
-| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
-
-**Custom keybindings example:**
+`keybindings` maps keys to mpv commands or SubMiner actions. Your entries merge with the defaults. The full default list is on [Keyboard shortcuts](/shortcuts).
```json
{
"keybindings": [
- { "key": "ArrowRight", "command": ["seek", 5] },
- { "key": "ArrowLeft", "command": ["seek", -5] },
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
- { "key": "MBTN_FORWARD", "command": ["sub-seek", 1] },
- { "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
- { "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
+ { "key": "Space", "command": null }
]
}
```
-**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). Mouse buttons use mpv button names: `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`.
-
-**Disable a default binding:** Set command to `null`:
-
-```json
-{ "key": "Space", "command": null }
-```
-
-**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value.
-
-**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
-
-Supported, unclaimed single-key keyboard bindings from the connected mpv session are also available
-in the overlay automatically. Configured SubMiner bindings, including `null` entries,
-take precedence. See [mpv binding discovery](/shortcuts#automatic-mpv-bindings) for session refresh
-behavior and limitations.
-
-Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
-
-**See `config.example.jsonc`** for more keybinding examples and configuration options.
+- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`.
+- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
+- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:[:next|prev]`.
+- Unused single-key bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
### Shortcuts configuration
-Customize or disable the overlay keyboard shortcuts:
+`shortcuts` holds SubMiner's own actions (mining, copying, opening modals). Values are [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts) such as `"CommandOrControl+S"`. Set one to `null` to disable it. [Keyboard shortcuts](/shortcuts) lists every key, its default, and what it does. Anki shortcuts only run when `ankiConnect.enabled` is on.
-See `config.example.jsonc` for detailed configuration options.
-
-```json
-{
- "shortcuts": {
- "toggleVisibleOverlayGlobal": "Alt+Shift+O",
- "copySubtitle": "CommandOrControl+C",
- "copySubtitleMultiple": "CommandOrControl+Shift+C",
- "updateLastCardFromClipboard": "CommandOrControl+V",
- "triggerFieldGrouping": "CommandOrControl+G",
- "triggerSubsync": "Ctrl+Alt+S",
- "mineSentence": "CommandOrControl+S",
- "mineSentenceMultiple": "CommandOrControl+Shift+S",
- "markAudioCard": "CommandOrControl+Shift+A",
- "openCharacterDictionaryManager": "CommandOrControl+D",
- "openRuntimeOptions": "CommandOrControl+Shift+O",
- "openSessionHelp": "CommandOrControl+Slash",
- "openControllerSelect": "Alt+C",
- "openControllerDebug": "Alt+Shift+C",
- "openJimaku": "Ctrl+Shift+J",
- "toggleSubtitleSidebar": "Backslash",
- "toggleNotificationHistory": "CommandOrControl+N",
- "appendClipboardVideoToQueue": "CommandOrControl+A",
- "multiCopyTimeoutMs": 3000
- }
-}
-```
-
-| Option | Values | Description |
-| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
-| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
-| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
-| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
-| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
-| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
-| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
-| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
-| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
-| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
-| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
-| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
-| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
-| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
-| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
-| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
-| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
-| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
-| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
-| `appendClipboardVideoToQueue` | string \| `null` | Appends a video file path from the clipboard to the mpv playlist (default: `"CommandOrControl+A"`). Works whether the overlay or mpv has focus. |
-
-**See `config.example.jsonc`** for the complete list of shortcut configuration options.
-
-Set any shortcut to `null` to disable it.
-
-Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
+| Key | Default | What it does |
+| ------------------------------ | ------- | -------------------------------------------------------- |
+| `shortcuts.multiCopyTimeoutMs` | `3000` | How long multi-copy and multi-mine wait for a digit (ms) |
### Controller support
-SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
+Gamepad input for the overlay, through the browser Gamepad API. It only works while keyboard-only mode is on. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
-Important behavior:
+| Key | Default | What it does |
+| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
+| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
+| `controller.smoothScroll` | `true` | Smooth popup scrolling |
+| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
+| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
+| `controller.stickDeadzone` | `0.2` | Stick deadzone |
+| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
+| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
+| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
+| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
-- Controller input is only active while keyboard-only mode is enabled.
-- Keyboard-only mode continues to work normally without a controller.
-- By default SubMiner uses the first connected controller.
-- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
-- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
-- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
-- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
-- Click the reset button beside the edit pencil to restore one binding to the built-in default.
-- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
-- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
-- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
-- The button-index map is a semantic reference mapping. Changing it does not rewrite the raw numeric descriptor values already stored under controller bindings.
-- Turning keyboard-only mode off clears the keyboard-only token highlight state.
-- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
-
-```jsonc
-{
- "controller": {
- "enabled": true,
- "preferredGamepadId": "",
- "preferredGamepadLabel": "",
- "smoothScroll": true,
- "scrollPixelsPerSecond": 900,
- "horizontalJumpPixels": 160,
- "stickDeadzone": 0.2,
- "triggerInputMode": "auto",
- "triggerDeadzone": 0.5,
- "repeatDelayMs": 320,
- "repeatIntervalMs": 120,
- "buttonIndices": {
- "select": 6,
- "buttonSouth": 0,
- "buttonEast": 1,
- "buttonWest": 2,
- "buttonNorth": 3,
- "leftShoulder": 4,
- "rightShoulder": 5,
- "leftStickPress": 9,
- "rightStickPress": 10,
- "leftTrigger": 6,
- "rightTrigger": 7,
- },
- "bindings": {
- "toggleLookup": { "kind": "button", "buttonIndex": 0 },
- "closeLookup": { "kind": "button", "buttonIndex": 1 },
- "toggleKeyboardOnlyMode": { "kind": "button", "buttonIndex": 3 },
- "mineCard": { "kind": "button", "buttonIndex": 2 },
- "quitMpv": { "kind": "button", "buttonIndex": 6 },
- "previousAudio": { "kind": "none" },
- "nextAudio": { "kind": "button", "buttonIndex": 5 },
- "playCurrentAudio": { "kind": "button", "buttonIndex": 4 },
- "toggleMpvPause": { "kind": "button", "buttonIndex": 9 },
- "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
- "leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
- "rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
- "rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
- },
- "profiles": {
- "Xbox Wireless Controller": {
- "label": "Xbox Wireless Controller",
- "bindings": {
- "toggleLookup": { "kind": "button", "buttonIndex": 0 },
- "mineCard": { "kind": "button", "buttonIndex": 2 },
- },
- },
- },
- },
-}
-```
-
-Default logical mapping:
-
-- Left stick up/down: scroll Yomitan popup
-- Left stick left/right: move subtitle token selection
-- Right stick up/down: page-jump through Yomitan popup
-- Right stick left/right: unused by default
-- `A`: toggle lookup
-- `B`: close lookup
-- `Y`: toggle keyboard-only mode
-- `X`: mine card
-- `Minus` / `Select`: quit mpv
-- `L1`: play current Yomitan audio (falls back to the first available track)
-- `R1`: move to the next available Yomitan audio track
-- `L3`: toggle mpv pause
-- `L2` / `R2`: unbound by default
-
-Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles[""]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
-
-If you bind a discrete action to an axis manually, include `direction`:
-
-```jsonc
-{
- "controller": {
- "bindings": {
- "toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" },
- },
- },
-}
-```
-
-Treat the button-index map as reference-only unless you are copying values from the debug modal. Updating it alone does not rewrite the hardcoded raw numeric values already present in controller bindings or controller profiles. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
-
-If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `digital` forces pressed/not-pressed handling; `auto` accepts either style and remains the default.
-
-If one controller reports non-standard raw button numbers, override that controller profile's button-index map using values from the `Alt+Shift+C` debug modal. Use the global button-index map only when the mapping should apply to every controller without a profile.
-
-If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
-
-Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
-
-### Manual card update shortcuts
-
-When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
-
-| Shortcut | Action |
-| -------------- | ------------------------------------------------------------------------------------------------------------- |
-| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
-| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
-| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
-| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) |
-| `Ctrl+S` | Create a sentence card from the current subtitle line |
-| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
-| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
-| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
-| `Ctrl+D` | Open loaded character dictionary manager |
-| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
-| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (configurable via `shortcuts.appendClipboardVideoToQueue`) |
-
-**Multi-line copy workflow:**
-
-1. Press `Ctrl+Shift+C`
-2. Press a number key (`1-9`) within 3 seconds
-3. The specified number of most recent subtitle lines are copied
-4. Press `Ctrl+V` to update the last added card with the copied lines
-
-These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
-
-### Session help modal
-
-The session help modal opens from the overlay with `Ctrl/Cmd+/` by default. The mpv plugin also exposes it through the `y-h` chord. It shows the current session keybindings and color legend.
-
-You can filter the modal quickly with `/`:
-
-- Type any part of the action name or shortcut in the search bar.
-- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
-- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
-
-While the modal is open:
-
-- `Esc`: close the modal (or clear the filter when text is entered)
-- `↑/↓`, `j/k`: move selection
-- Mouse/trackpad: click to select and activate rows
-
-The list is generated at runtime from:
-
-- Your active mpv keybindings (`keybindings`).
-- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
-- Current subtitle color settings from `subtitleStyle`.
-
-When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
-
-### Runtime option palette
-
-Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
-
-Current runtime options cover automatic card updates, media timing review,
-known-word highlighting, known-word maturity coloring, N+1 annotation, JLPT
-underlines, frequency highlighting, known-word match mode, and Kiku field
-grouping mode.
-
-Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
-
-Default shortcut: `Ctrl+Shift+O`
-
-Palette controls:
-
-- `Arrow Up/Down`: select option
-- `Arrow Left/Right`: change selected value
-- `Enter`: apply selected value
-- `Esc`: close
+Bindings are set with `Alt+C` learn mode, which saves them per controller.
## Anki integration
### AnkiConnect
-Enable automatic Anki card creation and updates with media generation:
+Creates and updates Anki cards with sentence, audio, and screenshot. Needs the [AnkiConnect](https://github.com/FooSoft/anki-connect) add-on and ffmpeg. See [Anki integration](/anki-integration) for setup, the proxy, and media options in detail.
```json
{
"ankiConnect": {
- "enabled": true,
- "url": "http://127.0.0.1:8765",
- "pollingRate": 3000,
- "proxy": {
- "enabled": true,
- "host": "127.0.0.1",
- "port": 8766,
- "upstreamUrl": "http://127.0.0.1:8765"
- },
- "tags": ["SubMiner"],
- "deck": "Learning::Japanese",
- "fields": {
- "word": "Expression",
- "audio": "SentenceAudio",
- "image": "Picture",
- "sentence": "Sentence",
- "miscInfo": "MiscInfo"
- },
- "media": {
- "generateAudio": true,
- "generateImage": true,
- "imageType": "static",
- "imageFormat": "jpg",
- "imageQuality": 92,
- "imageMaxWidth": 0,
- "imageMaxHeight": 0,
- "animatedFps": 10,
- "animatedMaxWidth": 640,
- "animatedMaxHeight": 0,
- "animatedCrf": 35,
- "normalizeAudio": true,
- "mirrorMpvVolume": true,
- "reviewTiming": false,
- "audioPadding": 0,
- "fallbackDuration": 3,
- "maxMediaDuration": 30
- },
- "behavior": {
- "autoUpdateNewCards": true,
- "overwriteAudio": true,
- "overwriteImage": true
- },
- "metadata": {
- "pattern": "[SubMiner] %f (%t)"
- },
- "isLapis": {
- "enabled": false,
- "sentenceCardModel": "Lapis"
- },
- "isKiku": {
- "enabled": false,
- "fieldGrouping": "disabled",
- "deleteDuplicateInAuto": true
- }
+ "deck": "Mining",
+ "fields": { "audio": "SentenceAudio", "image": "Picture" },
+ "knownWords": { "highlightEnabled": true, "decks": { "Mining": ["Expression"] } }
}
}
```
-This example is intentionally compact. The option table below documents available `ankiConnect` settings and behavior.
+**Connection**
-**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
+| Key | Default | What it does |
+| ------------------------------- | ------------------------- | ------------------------------------------------------------------------------ |
+| `ankiConnect.enabled` | `true` | Enable Anki integration |
+| `ankiConnect.url` | `"http://127.0.0.1:8765"` | AnkiConnect URL |
+| `ankiConnect.pollingRate` | `3000` | Milliseconds between checks for new cards (polling mode) |
+| `ankiConnect.proxy.enabled` | `true` | Run a local AnkiConnect proxy so cards added through it are updated right away |
+| `ankiConnect.proxy.host` | `"127.0.0.1"` | Proxy bind host |
+| `ankiConnect.proxy.port` | `8766` | Proxy bind port |
+| `ankiConnect.proxy.upstreamUrl` | `"http://127.0.0.1:8765"` | Where the proxy forwards requests |
+| `ankiConnect.tags` | `["SubMiner"]` | Tags added to mined and updated cards. `[]` disables |
+| `ankiConnect.deck` | `""` | Deck for duplicate checks and enrichment. Empty uses Yomitan's mining deck |
-| Option | Values | Description |
-| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
-| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
-| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
-| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
-| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
-| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
-| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
-| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
-| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
-| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
-| `fields.audio` | string | Card field for 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) |
-| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
-| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
-| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
-| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
-| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
-| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
-| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
-| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`). JPG values are mapped onto FFmpeg's 2-31 quality scale; WebP uses the value directly. |
-| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
-| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
-| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
-| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
-| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
-| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
-| `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) | 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"`) |
-| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
-| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
-| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
-| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
-| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
-| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
-| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
-| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
-| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
-| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
-| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
-| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
-| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, ` `=newline |
-| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
-| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
-| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
+**Fields**
-### Kiku/Lapis integration
+| Key | Default | What it does |
+| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| `ankiConnect.fields.word` | `"Expression"` | Word field |
+| `ankiConnect.fields.audio` | `"ExpressionAudio"` | Field that receives sentence audio. Set a separate field such as `SentenceAudio` so it does not overwrite Yomitan's word audio |
+| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
+| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
+| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
+| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
+| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, ` ` newline |
-SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings.
+**Media**
-```jsonc
-"ankiConnect": {
- "isLapis": {
- "enabled": true,
- "sentenceCardModel": "Japanese sentences"
- },
- "isKiku": {
- "enabled": true,
- "fieldGrouping": "manual",
- "deleteDuplicateInAuto": true
- },
- "lapisKiku": {
- "wordCardKind": "word-and-sentence"
- }
-}
-```
+| Key | Default | What it does |
+| ------------------------------------------------ | ---------- | ------------------------------------------------------------ |
+| `ankiConnect.media.generateAudio` | `true` | Cut a sentence audio clip |
+| `ankiConnect.media.generateImage` | `true` | Capture a screenshot or animation |
+| `ankiConnect.media.imageType` | `"static"` | `static` or `avif` (animated) |
+| `ankiConnect.media.imageFormat` | `"jpg"` | Static format: `jpg`, `png`, `webp` |
+| `ankiConnect.media.imageQuality` | `92` | JPG/WebP quality. PNG ignores it |
+| `ankiConnect.media.imageMaxWidth` | `0` | Max static width in px. `0` keeps the source size |
+| `ankiConnect.media.imageMaxHeight` | `0` | Max static height in px. `0` keeps the source size |
+| `ankiConnect.media.animatedFps` | `10` | AVIF frame rate |
+| `ankiConnect.media.animatedMaxWidth` | `640` | AVIF max width |
+| `ankiConnect.media.animatedMaxHeight` | `0` | AVIF max height. `0` keeps the aspect ratio |
+| `ankiConnect.media.animatedCrf` | `35` | AVIF quality. Lower is better and larger |
+| `ankiConnect.media.syncAnimatedImageToWordAudio` | `true` | Hold the first AVIF frame for the length of the word audio |
+| `ankiConnect.media.normalizeAudio` | `true` | Normalize clip loudness |
+| `ankiConnect.media.mirrorMpvVolume` | `true` | Apply mpv's current volume to the clip |
+| `ankiConnect.media.reviewTiming` | `false` | Pause and let you adjust clip timing before media is created |
+| `ankiConnect.media.audioPadding` | `0` | Seconds added to both ends of audio and AVIF clips |
+| `ankiConnect.media.fallbackDuration` | `3` | Clip length in seconds when subtitle timing is missing |
+| `ankiConnect.media.maxMediaDuration` | `30` | Longest allowed clip in seconds. `0` removes the cap |
-- Enable `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model.
-- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
-- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
-- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
-- For [Senren](https://github.com/BrenoAqua/Senren) note types, enable `isSenren` instead of `isKiku`. Duplicate merges then use Senren's scene-switching markup (including grouped `miscInfo` entries), and `isSenren.fieldGrouping` supports the same three modes (default: `auto`). Kiku and Senren are mutually exclusive; if both are enabled, Kiku wins and Senren is turned off with a config warning.
-- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
+**Behavior**
+
+| Key | Default | What it does |
+| ----------------------------------------- | ----------- | ------------------------------------------------------------------------ |
+| `ankiConnect.behavior.autoUpdateNewCards` | `true` | Fill new cards automatically. When off, use the manual shortcuts |
+| `ankiConnect.behavior.overwriteAudio` | `true` | Replace existing audio. When off, add alongside it |
+| `ankiConnect.behavior.overwriteImage` | `true` | Replace existing images. When off, add alongside them |
+| `ankiConnect.behavior.mediaInsertMode` | `"append"` | `append` or `prepend` when not overwriting |
+| `ankiConnect.behavior.highlightWord` | `true` | Bold the mined word in the sentence field |
+| `ankiConnect.behavior.notificationType` | `"overlay"` | Where mining and status messages go: `overlay`, `system`, `both`, `none` |
+
+**Known words and N+1**
+
+| Key | Default | What it does |
+| ------------------------------------------------- | ------------ | -------------------------------------------------------------------------------- |
+| `ankiConnect.knownWords.highlightEnabled` | `false` | Highlight words that already exist in your Anki decks |
+| `ankiConnect.knownWords.decks` | `{}` | Decks and word fields to read, for example `{ "Kaishi 1.5k": ["Word"] }` |
+| `ankiConnect.knownWords.matchMode` | `"headword"` | Match by `headword` or `surface` text |
+| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between cache refreshes |
+| `ankiConnect.knownWords.addMinedWordsImmediately` | `true` | Add newly mined words to the cache right away |
+| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity using `subtitleStyle.knownWordMaturityColors` |
+| `ankiConnect.knownWords.matureThresholdDays` | `21` | Interval in days at which a card counts as mature |
+| `ankiConnect.nPlusOne.enabled` | `false` | Highlight the only unknown word in a sentence. Needs known-word data |
+| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum words in a sentence before N+1 applies |
+
+Use word fields such as `Expression` or `Word` in `knownWords.decks`, not reading fields. See [Subtitle annotations](/subtitle-annotations) for how matching and maturity tiers work.
+
+### Kiku/Lapis integration {#kiku-lapis-integration}
+
+Note-type behavior for [Lapis](https://github.com/donkuri/lapis), [Kiku](https://kiku.youyoumu.my.id/), and [Senren](https://github.com/BrenoAqua/Senren). With both Lapis and Kiku on, Kiku handles duplicates and the sentence-card model comes from `isLapis`. Kiku and Senren are mutually exclusive. If both are on, Kiku wins and SubMiner logs a warning. See [Anki integration](/anki-integration) for details.
+
+| Key | Default | What it does |
+| -------------------------------------------- | --------------------- | ------------------------------------------------------ |
+| `ankiConnect.isLapis.enabled` | `false` | Mine dedicated sentence cards (`IsSentenceCard`) |
+| `ankiConnect.isLapis.sentenceCardModel` | `"Lapis"` | Note type used for sentence cards |
+| `ankiConnect.isKiku.enabled` | `false` | Merge duplicate word cards |
+| `ankiConnect.isKiku.fieldGrouping` | `"disabled"` | `auto`, `manual`, or `disabled`. See below |
+| `ankiConnect.isKiku.deleteDuplicateInAuto` | `true` | Delete the duplicate after an `auto` merge |
+| `ankiConnect.isSenren.enabled` | `false` | Merge duplicates using Senren's scene-switching format |
+| `ankiConnect.isSenren.fieldGrouping` | `"auto"` | `auto`, `manual`, or `disabled` |
+| `ankiConnect.isSenren.deleteDuplicateInAuto` | `true` | Delete the duplicate after an `auto` merge |
+| `ankiConnect.lapisKiku.wordCardKind` | `"word-and-sentence"` | Card-type flag set on word cards. See below |
### Word card type
-When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
+When SubMiner fills the sentence on a word card, it sets one card-type flag and clears the others. Only applies while `isLapis` or `isKiku` is on. Cards from Mine Sentence and Mine Audio keep their own flag.
-| Value | Flag set |
+| `wordCardKind` | Flag set |
| ----------------------------- | ----------------------- |
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
| `click` | `IsClickCard` |
| `sentence` | `IsSentenceCard` |
| `audio` | `IsAudioCard` |
-| `none` | none; flags left as-is |
-
-The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
-
-### N+1 word highlighting
-
-When known-word highlighting is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
-
-Known-word cache policy:
-
-- Initial sync runs when the integration starts if the cache is missing or stale.
-- The refresh interval controls the minimum time between syncs; between refreshes, cached words are reused without querying Anki.
-- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
-- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
-- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
-- Set `ankiConnect.knownWords.maturityEnabled` to `true` to color known words by Anki card maturity instead, using the four `subtitleStyle.knownWordMaturityColors` tiers. See [Known-Word Maturity Highlighting](/subtitle-annotations#known-word-maturity-highlighting) for how tiers are derived. Changing it or `matureThresholdDays` forces a full cache refresh.
-- The known-word deck map accepts an object keyed by deck name.
-- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
-- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
-- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
-- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set known-word matching to `"surface"` for raw subtitle text matching.
-- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
-- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
-- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts.
-
-To refresh roughly once per day, set:
-
-```json
-{
- "ankiConnect": {
- "knownWords": {
- "highlightEnabled": true,
- "refreshMinutes": 1440
- },
- "nPlusOne": {
- "minSentenceWords": 3
- }
- }
-}
-```
+| `none` | none, flags left as-is |
### Field grouping modes
-| Mode | Behavior |
-| ---------- | -------------------------------------------------------------------------------------------------------------------------- |
-| `auto` | Automatically merges the new card's content into the original; duplicate deletion is controlled by `deleteDuplicateInAuto` |
-| `manual` | Shows an overlay popup to choose which card to keep and whether to delete the duplicate after merge |
-| `disabled` | No field grouping; duplicate cards are left as-is |
-
-`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
-When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
+| Mode | What happens when you mine a duplicate |
+| ---------- | ---------------------------------------------------------------------------------------------------------- |
+| `auto` | Merges the new card into the existing one. `deleteDuplicateInAuto` decides whether the new card is deleted |
+| `manual` | Pauses playback and opens a dialog to choose which card to keep and whether to delete the other |
+| `disabled` | Leaves both cards as they are |
-Open demo in a new tab
-
-## Subtitle Selection
-
-Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals.
-
-Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available.
-
-`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults.
-
## External integrations
### Jimaku
-Configure Jimaku API access and defaults:
+Search and download Japanese subtitles from [Jimaku](https://jimaku.cc). See [Jimaku integration](/jimaku-integration).
-```json
-{
- "jimaku": {
- "apiKey": "YOUR_API_KEY",
- "apiKeyCommand": "cat ~/.jimaku_key",
- "apiBaseUrl": "https://jimaku.cc",
- "languagePreference": "ja",
- "maxEntryResults": 10
- }
-}
-```
-
-Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
+| Key | Default | What it does |
+| --------------------------- | --------------------- | ---------------------------------------------------------- |
+| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
+| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
+| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
+| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
+| `jimaku.maxEntryResults` | `10` | Maximum search results |
### TsukiHime
-TsukiHime subtitle search works out of the box and needs no account or API key. It does require the `xz` binary on your `PATH`, because TsukiHime serves extracted subtitles xz-compressed.
+Subtitle search that needs no account or key. It does need `xz` on your `PATH`. The shortcut is `shortcuts.openTsukihime`. See [TsukiHime integration](/tsukihime-integration).
-```json
-{
- "tsukihime": {
- "apiBaseUrl": "https://api.tsukihime.org/v1",
- "maxSearchResults": 10
- }
-}
-```
-
-| Option | Values | Description |
-| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
-| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
-| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
-
-The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
-
-See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
+| Key | Default | What it does |
+| ---------------------------- | -------------------------------- | ---------------------------------------------------- |
+| `tsukihime.apiBaseUrl` | `"https://api.tsukihime.org/v1"` | API base URL. Only change it for a mirror |
+| `tsukihime.maxSearchResults` | `10` | Maximum releases per search (the API caps it at 100) |
### 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.
+Posters, synopses, and show grouping for live-action titles in the stats [Library](/immersion-tracking). Release builds include a TMDB key, so you only need your own to use your own quota or when running from source. Get one free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api). Either the API key or the read access token works.
-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.
+| Key | Default | What it does |
+| -------------------- | ------- | ---------------------------------------------------------- |
+| `tmdb.apiKey` | `""` | Your TMDB key or token. Overrides the bundled key |
+| `tmdb.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
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.
+Transcribes Japanese subtitles locally with whisper.cpp. Open it with `Ctrl+Shift+G` (`shortcuts.openSubtitleGeneration`) or from the subtitle sidebar. See [Subtitle generation](/subtitle-generation).
-`subtitleGeneration` configures local Japanese transcription for both the launcher and overlay. In **Settings → Integrations → Japanese Subtitle Generation**, set `modelPath` to an existing multilingual whisper.cpp GGML model, or leave it empty and choose a `managedModel` as the default. The generation modal lets you select another model for the current session, with download sizes and accuracy versus speed guidance. Downloads are explicit. Leave `whisperPath`, `ffmpegPath`, and `ffprobePath` empty to find the executables on `PATH`, or set them to override the executable paths. `threads` controls the CPU thread count. Settings apply to the next operation. See [subtitle generation](/subtitle-generation) for setup and behavior, and the [generated configuration example](/config.example.jsonc) for defaults.
-
-The generation modal offers an optional **Focus on spoken dialogue** checkbox and a separate Silero model download. Set `subtitleGeneration.vadModelPath` to a Silero GGML VAD model to make dialogue mode the default. `vadPath` overrides the speech detector executable. See [dialogue generation setup](/subtitle-generation#prioritizing-spoken-dialogue) for session behavior, the additional tool, and limitations.
+| Key | Default | What it does |
+| --------------------------------- | --------- | ----------------------------------------------------------------------- |
+| `subtitleGeneration.modelPath` | `""` | Path to a multilingual whisper.cpp GGML model. Overrides `managedModel` |
+| `subtitleGeneration.managedModel` | `"small"` | Model SubMiner downloads and uses when `modelPath` is empty |
+| `subtitleGeneration.threads` | `4` | CPU threads |
+| `subtitleGeneration.vadModelPath` | `""` | Silero VAD model. Set it to focus on spoken dialogue by default |
+| `subtitleGeneration.whisperPath` | `""` | `whisper-cli` path. Empty searches `PATH` |
+| `subtitleGeneration.vadPath` | `""` | Speech detector path. Empty searches `PATH` |
+| `subtitleGeneration.ffmpegPath` | `""` | `ffmpeg` path. Empty searches `PATH` |
+| `subtitleGeneration.ffprobePath` | `""` | `ffprobe` path. Empty searches `PATH` |
### Subtitle sync
-Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
+Retimes a subtitle track with [`alass`](https://github.com/kaegi/alass) (against another subtitle or the video) or [`ffsubsync`](https://github.com/smacke/ffsubsync) (against the video's audio). Install them yourself. Open the picker with `Ctrl+Alt+S` (`shortcuts.triggerSubsync`).
-- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
-- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
+| Key | Default | What it does |
+| ------------------------ | ------- | ------------------------------------------------------------------- |
+| `subsync.alass_path` | `""` | `alass` path. Empty uses `/usr/bin/alass` |
+| `subsync.ffsubsync_path` | `""` | `ffsubsync` path. Empty uses `/usr/bin/ffsubsync` |
+| `subsync.ffmpeg_path` | `""` | `ffmpeg` path. Empty uses `/usr/bin/ffmpeg` |
+| `subsync.replace` | `true` | Overwrite the subtitle file. When off, write `_retimed.` |
-```json
-{
- "subsync": {
- "alass_path": "",
- "ffsubsync_path": "",
- "ffmpeg_path": "",
- "replace": true
- }
-}
-```
-
-| Option | Values | Description |
-| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
-| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
-| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
-| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
-| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `_retimed.`. |
-
-Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
-Customize it there, or set it to `null` to disable.
+If a tool lives somewhere else, such as on macOS or Windows, set its path.
### AniList
-AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
+Updates your AniList watch progress after an episode, and controls the character dictionary. With `enabled` on and no token, SubMiner opens a login window. See [AniList integration](/anilist-integration) and [Character dictionary](/character-dictionary).
-```json
-{
- "anilist": {
- "enabled": true,
- "accessToken": "",
- "characterDictionary": {
- "maxLoaded": 3,
- "profileScope": "all",
- "collapsibleSections": {
- "description": false,
- "characterInformation": false,
- "voicedBy": false
- }
- }
- }
-}
-```
-
-| Option | Values | Description |
-| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
-| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
-| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
-| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
-| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
-| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
-| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
-| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
-| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
-| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
-
-When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
-
-Character dictionary sync behavior:
-
-- Snapshot identity is still AniList **media ID**.
-- Sync/import runs only for the currently watched media when media path/title changes.
-- SubMiner keeps a most-recently-used list of synced AniList media snapshots and rebuilds one merged Yomitan dictionary from that active set.
-- `maxLoaded` controls how many recent AniList media snapshots stay in the merged dictionary at once.
-- The merged dictionary title stays stable as `SubMiner Character Dictionary`, so Yomitan sees one rotating dictionary instead of one dictionary per anime.
-
-Current post-watch behavior:
-
-- SubMiner attempts an update near episode completion using the shared default minimum watch ratio (`0.85`, or `>=85%`) from `src/shared/watch-threshold.ts`, and requires at least `10` minutes watched. The same ratio is also used by local episode watched state transitions.
-- Episode/title detection is `guessit`-first with fallback to SubMiner's filename parser.
-- If `guessit` is unavailable, updates still work via fallback parsing but title matching can be less accurate.
-- If embedded AniList auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app.
-- Failed updates are retried with a persistent backoff queue in the background.
-
-Setup flow details:
-
-1. Set `anilist.enabled` to `true`.
-2. Leave the AniList access-token field empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
-3. Approve access in AniList.
-4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
- - Encryption backend: Linux defaults to `gnome-libsecret`.
- Override with `--password-store=` (for example `--password-store=basic_text`).
-
-Token + detection notes:
-
-- The AniList access token can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup.
-- Detection quality is best when `guessit` is installed and available on `PATH`.
-- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing.
-
-AniList CLI commands:
-
-- `--anilist-status`: print current AniList token resolution state and retry queue counters.
-- `--anilist-logout`: clear stored AniList token from local persisted state.
-- `--anilist-setup`: open AniList setup/auth flow helper window.
-- `--anilist-retry-queue`: process one ready retry queue item immediately.
+| Key | Default | What it does |
+| ---------------------------------------------------------------------- | ------- | ------------------------------------------------------------- |
+| `anilist.enabled` | `false` | Enable progress updates |
+| `anilist.accessToken` | `""` | Token override. Empty uses the token saved during login |
+| `anilist.characterDictionary.maxLoaded` | `3` | How many recent shows stay in the merged character dictionary |
+| `anilist.characterDictionary.collapsibleSections.description` | `false` | Open the Description section by default |
+| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Open the Character Information section by default |
+| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Open the Voiced by section by default |
### Yomitan
-SubMiner normally uses its bundled Yomitan profile under the app config directory. If you want to reuse dictionaries and profile settings from another Electron app, point SubMiner at that app's Yomitan Electron profile in read-only mode.
+Point SubMiner at another app's Yomitan Electron profile to reuse its dictionaries and settings. For GameSentenceMiner on Linux this is usually `~/.config/gsm_overlay`.
-For GameSentenceMiner on Linux, the default overlay profile path is typically `~/.config/gsm_overlay`.
+| Key | Default | What it does |
+| ----------------------------- | ------- | ----------------------------------------------------------------------- |
+| `yomitan.externalProfilePath` | `""` | Absolute or `~` path to the external profile. Empty uses SubMiner's own |
-```json
-{
- "yomitan": {
- "externalProfilePath": "/home/you/.config/gsm_overlay"
- }
-}
-```
-
-| Option | Values | Description |
-| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
-
-External-profile mode behavior:
-
-- SubMiner uses the external profile's Yomitan extension/session instead of its local copy.
-- SubMiner reads the external profile's currently active Yomitan profile selection and installed dictionaries.
-- SubMiner does not open its own Yomitan settings window in this mode.
-- SubMiner does not import, delete, or update dictionaries/settings in the external profile.
-- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
-- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without an external Yomitan profile, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
+In external-profile mode, SubMiner only reads the profile. It does not open its own Yomitan settings, does not change dictionaries, and turns off all character-dictionary features.
### Jellyfin
-Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch.
+Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
-```json
-{
- "jellyfin": {
- "enabled": true,
- "serverUrl": "http://127.0.0.1:8096",
- "recentServers": ["http://127.0.0.1:8096"],
- "username": "",
- "remoteControlEnabled": true,
- "remoteControlAutoConnect": true,
- "autoAnnounce": false,
- "defaultLibraryId": "",
- "directPlayPreferred": true,
- "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
- "transcodeVideoCodec": "h264"
- }
-}
-```
-
-| Option | Values | Description |
-| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------ |
-| `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
-| `serverUrl` | string (URL) | Jellyfin server base URL |
-| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
-| `username` | string | Default username used by `--jellyfin-login` |
-| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
-| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
-| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires Jellyfin integration and remote control) |
-| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
-| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
-| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
-| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
-| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
-| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
-
-Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
-
-- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=` on launcher/app invocations when needed.
-
-Launcher subcommands:
-
-- `subminer jellyfin` (or `subminer jf`) opens setup.
-- `subminer jellyfin -l --server ... --username ... --password ...` logs in.
-- `subminer jellyfin --logout` clears stored credentials.
-- `subminer jellyfin -p` opens play picker.
-- `subminer jellyfin -d` starts cast discovery mode in background/tray mode.
-- These launcher commands also accept `--password-store=` to override the launcher-app forwarded Electron switch.
-
-See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
-
-Jellyfin remote auto-connect runs only when Jellyfin integration, remote control, and remote auto-connect are all enabled.
-
-Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
-
-When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
+| Key | Default | What it does |
+| ----------------------------------- | -------------------------------- | ----------------------------------------------- |
+| `jellyfin.enabled` | `false` | Enable Jellyfin |
+| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
+| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
+| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
+| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup |
+| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
+| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
+| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
+| `jellyfin.directPlayPreferred` | `true` | Try direct play before transcoding |
+| `jellyfin.transcodeVideoCodec` | `"h264"` | Codec requested when transcoding |
### Discord rich presence
-Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
+Shows what you are watching on your Discord profile. Needs the Discord desktop app running. If Discord is closed, SubMiner skips updates.
-```json
-{
- "discordPresence": {
- "enabled": true,
- "presenceStyle": "default",
- "updateIntervalMs": 3000,
- "debounceMs": 750
- }
-}
-```
-
-| Option | Values | Description |
-| ------------------------- | ------------------------------------------------ | ---------------------------------------------------------- |
-| `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
-| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
-| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
-| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
-
-Setup steps:
-
-1. Leave `discordPresence.enabled` as `true` or set it explicitly if you previously disabled it.
-2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
-3. Restart SubMiner.
-
-#### Presence style presets
-
-While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
-
-| Preset | Idle details | Small image text | Vibe |
-| ------------- | ---------------------------------- | ------------------ | --------------------------------------- |
-| **`default`** | `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
-| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
-| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
-| `minimal` | `SubMiner` | _(none)_ | Bare essentials, no small image overlay |
-
-All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
-
-Troubleshooting:
-
-- If the card does not appear, verify Discord desktop app is running.
-- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
-- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
+| Key | Default | What it does |
+| ---------------------------------- | ----------- | ------------------------------------------------------------------ |
+| `discordPresence.enabled` | `true` | Enable rich presence |
+| `discordPresence.presenceStyle` | `"default"` | Card text: `default`, `meme`, `japanese` (all Japanese), `minimal` |
+| `discordPresence.updateIntervalMs` | `3000` | Minimum ms between updates |
+| `discordPresence.debounceMs` | `750` | Debounce for bursts of playback events |
### Immersion tracking
-Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions. This data also powers the stats dashboard:
+Records watch sessions, subtitle lines, and mining in a local SQLite database that feeds the stats dashboard. See [Immersion tracking](/immersion-tracking) for retention and storage details. To turn it off for one run, start with `SUBMINER_DISABLE_IMMERSION_TRACKING=1 subminer`.
-```json
-{
- "immersionTracking": {
- "enabled": true,
- "dbPath": "",
- "batchSize": 25,
- "flushIntervalMs": 500,
- "queueCap": 1000,
- "payloadCapBytes": 256,
- "maintenanceIntervalMs": 86400000,
- "retentionMode": "preset",
- "retentionPreset": "balanced",
- "retention": {
- "eventsDays": 0,
- "telemetryDays": 0,
- "sessionsDays": 0,
- "dailyRollupsDays": 0,
- "monthlyRollupsDays": 0,
- "vacuumIntervalDays": 0
- },
- "lifetimeSummaries": {
- "global": true,
- "anime": true,
- "media": true
- }
- }
-}
-```
-
-| Option | Values | Description |
-| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
-| `immersionTracking.enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
-| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `/immersion.sqlite`. |
-| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
-| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
-| `queueCap` | integer (`100`-`100000`) | In-memory queue cap. Overflow drops oldest writes. Default `1000`. |
-| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. |
-| `maintenanceIntervalMs` | integer (`60000`-`604800000`) | Prune + rollup maintenance cadence. Default `86400000` (24h). |
-| `retentionMode` | `preset`,`advanced` | Retention mode. `preset` applies `retentionPreset`, `advanced` uses explicit values only. Default `preset`. |
-| `retentionPreset` | `minimal`,`balanced`,`deep-history` | Retention preset used when `retentionMode = "preset"`. Default `balanced`. |
-| `retention.eventsDays` | integer (`0`-`3650`) | Raw event retention window in days. Default `0` (keep all). |
-| `retention.telemetryDays` | integer (`0`-`3650`) | Telemetry retention window in days. Default `0` (keep all). |
-| `retention.sessionsDays` | integer (`0`-`3650`) | Session retention window in days. Default `0` (keep all). |
-| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
-| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
-| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
-| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
-| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
-| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
-
-You can also disable immersion tracking for a single session using:
-
-```bash
-SUBMINER_DISABLE_IMMERSION_TRACKING=1 subminer
-```
-
-When this is set, SubMiner skips immersion-tracker startup and does not initialize or read the immersion SQLite database for that session.
-
-Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values.
-
-When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location:
-
-```text
-/immersion.sqlite
-```
-
-Set `dbPath` only if you want to relocate the database (for backup, syncing, or inspection workflows). The database is created when tracking starts for the first time.
-
-See [Immersion Tracking Storage](/immersion-tracking) for schema details, query templates, dashboard access, retention/rollup behavior, backend portability notes, and the dedicated SQLite verification command.
+| Key | Default | What it does |
+| ------------------------------------------------ | ------------ | ----------------------------------------------------------------- |
+| `immersionTracking.enabled` | `true` | Enable tracking |
+| `immersionTracking.dbPath` | `""` | Database path. Empty uses `immersion.sqlite` in the config folder |
+| `immersionTracking.batchSize` | `25` | Writes per transaction |
+| `immersionTracking.flushIntervalMs` | `500` | Maximum ms before queued writes are saved |
+| `immersionTracking.queueCap` | `1000` | Queue size. The oldest writes drop when full |
+| `immersionTracking.payloadCapBytes` | `256` | Maximum event payload size before truncation |
+| `immersionTracking.maintenanceIntervalMs` | `86400000` | How often pruning and rollups run (24 h) |
+| `immersionTracking.retentionMode` | `"preset"` | `preset` uses `retentionPreset`. `advanced` uses `retention.*` |
+| `immersionTracking.retentionPreset` | `"balanced"` | `minimal`, `balanced`, or `deep-history` |
+| `immersionTracking.retention.eventsDays` | `0` | Days to keep raw events. `0` keeps everything |
+| `immersionTracking.retention.telemetryDays` | `0` | Days to keep telemetry |
+| `immersionTracking.retention.sessionsDays` | `0` | Days to keep sessions |
+| `immersionTracking.retention.dailyRollupsDays` | `0` | Days to keep daily rollups |
+| `immersionTracking.retention.monthlyRollupsDays` | `0` | Days to keep monthly rollups |
+| `immersionTracking.retention.vacuumIntervalDays` | `0` | Days between `VACUUM` runs. `0` disables |
+| `immersionTracking.lifetimeSummaries.global` | `true` | Keep all-time totals |
+| `immersionTracking.lifetimeSummaries.anime` | `true` | Keep per-show totals |
+| `immersionTracking.lifetimeSummaries.media` | `true` | Keep per-file totals |
### Stats dashboard
-Configure the local stats UI served from SubMiner and the in-app stats overlay toggle:
+A local web dashboard at `http://127.0.0.1:`, also available as an overlay inside SubMiner. It reads the immersion tracking database, so tracking must be on. See [Immersion tracking](/immersion-tracking).
-```json
-{
- "stats": {
- "toggleKey": "Backquote",
- "markWatchedKey": "KeyW",
- "serverPort": 6969,
- "autoStartServer": true,
- "autoOpenBrowser": false
- }
-}
-```
-
-| Option | Values | Description |
-| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
-| `stats.toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
-| `markWatchedKey` | Electron key code | Key code to mark the current video as watched and advance to the next playlist entry. Default `KeyW`. |
-| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
-| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
-| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
-
-Usage notes:
-
-- The browser UI is served at `http://127.0.0.1:`.
-- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
-- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
-- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
+| Key | Default | What it does |
+| ----------------------- | ------------- | ------------------------------------------------------------------ |
+| `stats.toggleKey` | `"Backquote"` | Key that toggles the stats overlay (overlay focus only) |
+| `stats.markWatchedKey` | `"KeyW"` | Key that marks the video watched and plays the next playlist entry |
+| `stats.serverPort` | `6969` | Dashboard port |
+| `stats.autoStartServer` | `true` | Start the dashboard server once tracking is active |
+| `stats.autoOpenBrowser` | `false` | Open the browser when `subminer stats` starts the server |
### MPV launcher
-Configure the mpv executable, profile, and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
+Settings for mpv instances that SubMiner starts, and for the bundled mpv plugin. See [mpv plugin](/mpv-plugin).
-```json
-{
- "mpv": {
- "executablePath": "",
- "launchMode": "normal",
- "profile": "",
- "socketPath": "/tmp/subminer-socket",
- "backend": "auto",
- "autoStartSubMiner": true,
- "pauseUntilOverlayReady": true,
- "subminerBinaryPath": "",
- "aniskipEnabled": true,
- "aniskipButtonKey": "TAB"
- }
-}
-```
-
-| Option | Values | Description |
-| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
-| `profile` | string | mpv profile name passed as `--profile=`. Leave empty to pass no profile (default `""`) |
-| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
-| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
-| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
-| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
-| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
-| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
-| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
-| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
-
-If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
-
-Launch mode behavior:
-
-- **`normal`** - mpv opens at its default window size with no extra flags.
-- **`maximized`** - mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
-- **`fullscreen`** - mpv starts in true fullscreen via `--fullscreen`.
+| Key | Default | What it does |
+| ---------------------------- | ----------------- | --------------------------------------------------------------------------- |
+| `mpv.executablePath` | `""` | Path to `mpv.exe` on Windows. Empty checks `SUBMINER_MPV_PATH`, then `PATH` |
+| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
+| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
+| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
+| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
+| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
+| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
+| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
+| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
+| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
### YouTube playback settings
-Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
+Language and card-media settings for YouTube playback. YouTube always loads a Japanese primary and English secondary track, preferring manual uploads over auto captions. See [YouTube integration](/youtube-integration).
-```json
-{
- "youtube": {
- "primarySubLanguages": ["ja", "jpn"],
- "mediaCache": {
- "mode": "direct",
- "maxHeight": 720
- }
- }
-}
-```
+| Key | Default | What it does |
+| ------------------------------ | --------------- | -------------------------------------------------------------------------------------------- |
+| `youtube.primarySubLanguages` | `["ja", "jpn"]` | Languages that count as a valid primary track, also used for local playback |
+| `youtube.mediaCache.mode` | `"direct"` | `direct` cuts card media from the stream. `background` downloads the video with yt-dlp first |
+| `youtube.mediaCache.maxHeight` | `720` | Maximum download height in `background` mode. `0` is unlimited |
-| Option | Values | Description |
-| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
-| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
-| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
-| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
-
-`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
-
-Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
-
-Current launcher behavior:
-
-- For YouTube URLs, SubMiner probes subtitle tracks with yt-dlp after mpv bootstrap and binds auto-selected tracks before normal playback resumes.
-- If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side.
-- SubMiner loads the primary subtitle plus a best-effort secondary subtitle.
-- Playback waits only for primary subtitle readiness; secondary failures do not block playback.
-- Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface.
-- If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track.
-
-Track selection:
-
-- YouTube auto-selection always targets a Japanese primary track and an English secondary track, preferring manual uploads over auto-generated captions.
-- `youtube.primarySubLanguages` (default `["ja","jpn"]`) defines which loaded track counts as a satisfactory primary for the "primary subtitle missing" notification and for managed local/playlist subtitle selection.
-- Local playback applies these priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
-- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
-
-These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
-
-#### YouTube subtitle generation (`youtubeSubgen`)
-
-An advanced, template-hidden section for Whisper-based YouTube subtitle generation: `whisperBin`, `whisperModel`, `whisperVadModel`, and `whisperThreads` (default `4`). These keys are accepted in `config.jsonc` but the generated template omits them.
+Use `background` if card media fails with YouTube `403` errors. Cards mined before the download finishes get their text right away and their audio and image once the file is ready.
diff --git a/docs-site/demos.md b/docs-site/demos.md
index c947fb40..667e1a90 100644
--- a/docs-site/demos.md
+++ b/docs-site/demos.md
@@ -2,7 +2,7 @@
Short recordings from real playback sessions.
-Some vocabulary for what follows. _Yomitan_ is the pop-up dictionary. _Jimaku_ is a community subtitle database. _alass_ and _ffsubsync_ retime subtitles against the audio. _Jellyfin_ is a self-hosted media server. A _texthooker_ is a web page that mirrors the current subtitle as selectable text so browser tools can read it.
+_Yomitan_ is the pop-up dictionary. _Jimaku_ is a community subtitle database. _alass_ and _ffsubsync_ retime subtitles against the audio. _Jellyfin_ is a self-hosted media server. A _texthooker_ is a web page that mirrors the current subtitle as selectable text.
```
-### Build a custom node client
+A Node client:
```js
import WebSocket from 'ws';
@@ -238,112 +174,15 @@ ws.on('message', (raw) => {
});
```
-### Integration tips
+Tips:
-- Bind only to `127.0.0.1`; these services are local-only by design.
-- Handle empty `tokens` arrays gracefully because subtitle text can arrive before tokenization completes.
-- Reconnect on disconnect; SubMiner does not manage client reconnects for you.
-- Prefer `payload.text` for logging/automation and `payload.sentence` or `payload.tokens` for UI rendering.
+- Handle empty `tokens` arrays. Text can arrive before tokenization finishes.
+- Reconnect on disconnect yourself.
+- Use `text` for logging and automation, and `sentence` or `tokens` for display.
-## Plugin development
+### Forward lines to a webhook
-SubMiner does **not** currently expose a general-purpose third-party plugin SDK inside the app itself. Today, the supported extension surfaces are:
-
-1. the local websocket streams
-2. the local texthooker UI
-3. the mpv Lua plugin's script-message API
-4. the launcher CLI
-
-### mpv script messages
-
-The mpv plugin accepts these script messages:
-
-```text
-script-message subminer-start
-script-message subminer-stop
-script-message subminer-toggle
-script-message subminer-menu
-script-message subminer-options
-script-message subminer-restart
-script-message subminer-status
-script-message subminer-autoplay-ready
-script-message subminer-stats-toggle
-script-message subminer-visible-overlay-shown
-script-message subminer-visible-overlay-hidden
-script-message subminer-managed-subtitles-loading
-script-message subminer-overlay-loading-ready
-script-message subminer-reload-session-bindings
-```
-
-The overlay/loading/session-binding messages are primarily sent by the SubMiner app to keep the plugin's state in sync. The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) are handled by the SubMiner app over the mpv IPC socket while it is connected.
-
-The start command also accepts inline overrides:
-
-```text
-script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
-```
-
-### Practical extension patterns
-
-#### Add another mpv script that coordinates with SubMiner
-
-Examples:
-
-- send `subminer-start` after your own media-selection script chooses a file
-- send `subminer-status` before running follow-up automation
-- send `subminer-aniskip-refresh` after you update title/episode metadata (handled by the SubMiner app)
-
-#### Build a launcher wrapper
-
-Examples:
-
-- open a media picker, then call `subminer /path/to/file.mkv`
-- launch browser-only subtitle tooling with `subminer texthooker -o`
-- disable the helper UI for a session with `subminer --no-texthooker video.mkv`
-
-#### Build an overlay-adjacent client
-
-Examples:
-
-- browser widget showing current subtitle + token breakdown
-- local vocabulary capture helper that writes interesting lines to a file
-- bridge service that forwards websocket events into your own workflow engine
-
-## Webhook examples
-
-SubMiner does **not** currently send outbound webhooks by itself. The supported pattern is to consume the websocket locally and relay events into another system.
-
-That still makes webhook-style automation straightforward.
-
-### Example: forward subtitle lines to a local webhook receiver
-
-```js
-import WebSocket from 'ws';
-
-const ws = new WebSocket('ws://127.0.0.1:6678');
-
-ws.on('message', async (raw) => {
- const payload = JSON.parse(String(raw));
-
- await fetch('http://127.0.0.1:5678/subminer/subtitle', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({
- text: payload.text,
- tokens: payload.tokens,
- receivedAt: new Date().toISOString(),
- }),
- });
-});
-```
-
-### Automation ideas
-
-- **n8n / Make / Zapier relay:** send each subtitle line into an automation workflow for logging, translation, or summarization.
-- **Discord / Slack notifier:** post only lines that contain unknown words or N+1 targets.
-- **Obsidian / Markdown capture:** append subtitle lines plus token metadata to a daily immersion note.
-
-### Filtering example: only forward N+1 lines
+SubMiner does not send webhooks itself. Relay the stream to your own endpoint instead. This example forwards only lines that contain an N+1 target:
```js
import WebSocket from 'ws';
@@ -353,7 +192,6 @@ const ws = new WebSocket('ws://127.0.0.1:6678');
ws.on('message', async (raw) => {
const payload = JSON.parse(String(raw));
const hasNPlusOne = payload.tokens.some((token) => token.isNPlusOneTarget);
-
if (!hasNPlusOne) return;
await fetch('http://127.0.0.1:5678/subminer/n-plus-one', {
@@ -364,18 +202,39 @@ ws.on('message', async (raw) => {
});
```
-## Recommended integration combinations
+The same pattern works for n8n or Zapier workflows, Discord notifiers, or appending lines to a notes file.
-- **Browser Yomitan client:** `texthooker` + `annotationWebsocket`
-- **Custom dashboard:** `annotationWebsocket` only
-- **Lightweight subtitle mirror:** `websocket` only
-- **mpv-side automation:** mpv plugin script messages + optional websocket relay
-- **Webhook-style workflows:** `annotationWebsocket` + your own local relay service
+## mpv script messages
+
+SubMiner has no in-app plugin SDK. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
+
+The mpv plugin accepts these script messages:
+
+| Message | Action |
+| ----------------------- | ------------------------------------------ |
+| `subminer-start` | Start the overlay |
+| `subminer-stop` | Stop the overlay |
+| `subminer-toggle` | Toggle the visible overlay |
+| `subminer-menu` | Open the plugin menu |
+| `subminer-options` | Open the SubMiner settings window |
+| `subminer-restart` | Restart the overlay |
+| `subminer-status` | Show overlay status on the mpv OSD |
+| `subminer-stats-toggle` | Show an OSD hint for the overlay stats key |
+
+`subminer-start` accepts overrides for `backend` (`auto`, `hyprland`, `sway`, `x11`, `macos`), `socket`, `texthooker`, and `log-level`:
+
+```text
+script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
+```
+
+The plugin also registers `subminer-autoplay-ready`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
+
+While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata.
## Related pages
-- [Configuration](/configuration#websocket-server)
-- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
-- [MPV Plugin](/mpv-plugin)
-- [Launcher Script](/launcher-script)
-- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
+- [Configuration](/configuration)
+- [Mining workflow](/mining-workflow)
+- [mpv plugin](/mpv-plugin)
+- [Launcher script](/launcher-script)
+- [Anki integration](/anki-integration)
diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md
index a9c48df6..8017cccb 100644
--- a/docs-site/youtube-integration.md
+++ b/docs-site/youtube-integration.md
@@ -1,162 +1,63 @@
# YouTube integration
-Play a YouTube URL and SubMiner loads Japanese subtitles for it, so mining works the same as it does on a local file. It probes the available tracks with `yt-dlp`, picks a primary and a secondary, downloads both, and loads them into mpv before playback resumes.
+Play a YouTube URL and SubMiner downloads its Japanese subtitles and loads them into mpv, so you can mine from it like a local file.
-## Requirements
+## Setup
-- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** must be installed and on your `PATH`. yt-dlp is a free command-line tool that reads YouTube video and subtitle info; SubMiner calls it behind the scenes. (`PATH` is the list of folders your system searches for programs - most installers add yt-dlp to it automatically. If yours did not, set `SUBMINER_YTDLP_BIN` to the full path of the yt-dlp binary.)
-- mpv with `--input-ipc-server` configured (handled automatically when you launch playback through the `subminer` launcher - no manual setup needed).
+Install [yt-dlp](https://github.com/yt-dlp/yt-dlp) and make sure it is on your `PATH`. If it is somewhere else, set `SUBMINER_YTDLP_BIN` to the full path of the binary.
-## How it works
+## Usage
-When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
-
-1. **Probe** - `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
-2. **Discover** - Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
-3. **Select** - SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
-4. **Download** - Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
-5. **Load** - Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block.
-
-## Pipeline diagram
-
-```mermaid
-flowchart TD
- classDef step fill:#c6a0f6,stroke:#494d64,color:#24273a
- classDef action fill:#8aadf4,stroke:#494d64,color:#24273a
- classDef result fill:#a6da95,stroke:#494d64,color:#24273a
- classDef enrich fill:#8bd5ca,stroke:#494d64,color:#24273a
- classDef ext fill:#eed49f,stroke:#494d64,color:#24273a
-
- A[YouTube URL detected]:::step
- B[yt-dlp probe]:::ext
- C[Track discovery]:::action
- D{Auto or manual selection?}:::step
- E[Auto-select best tracks]:::action
- F[Manual picker - Ctrl+Alt+C]:::action
- G[Download subtitle files]:::action
- H[Convert TimedText to VTT]:::enrich
- I[Normalize auto-caption duplicates]:::enrich
- K[sub-add into mpv]:::action
- L[Overlay renders subtitles]:::result
-
- A --> B
- B --> C
- C --> D
- D - startup --> E
- D - user request --> F
- E --> G
- F --> G
- G --> H
- H --> I
- I --> K
- K --> L
+```bash
+subminer https://www.youtube.com/watch?v=VIDEO_ID
+subminer ytsearch:"keyword" # plays the first search result
```
-## Auto-load flow
+mpv starts paused while SubMiner fetches the subtitle list. It picks a primary and a secondary track, loads them, and resumes playback once the primary track is ready. A playlist link plays only the linked video.
-On startup with a YouTube URL:
+SubMiner picks tracks in this order. Manual (uploaded) tracks win over auto-generated captions.
-1. mpv launches paused.
-2. SubMiner calls `yt-dlp --dump-single-json` to probe all subtitle tracks.
-3. Tracks are split into **manual** (human-uploaded) and **auto** (machine-generated) categories.
-4. The selection algorithm picks:
- - **Primary**: first Japanese manual track, then Japanese auto track, then any manual track, then first available track.
- - **Secondary**: first English manual track, then English auto track (excluding the primary).
-5. If mpv already exposes an authoritative matching track, SubMiner reuses it instead of downloading again.
-6. Missing tracks are downloaded to a temp directory and loaded via `sub-add`.
-7. Playback unpauses once the primary subtitle is ready.
+| Track | Choice |
+| --------- | -------------------------------------------------------------------------------- |
+| Primary | Japanese manual, then Japanese auto, then any manual track, then the first track |
+| Secondary | English manual, then English auto. Skipped if none exists. |
-## Manual subtitle picker
+Press `Ctrl+Alt+C` during playback to open the subtitle picker. It lists every track with its language and kind, and lets you choose different primary and secondary tracks or retry a failed load.
-Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay. This lets you:
+## Secondary subtitle languages
-- Browse all discovered tracks (manual and auto-generated)
-- Select different primary and secondary tracks
-- Retry track loading if the auto-load failed or picked the wrong track
+YouTube secondary selection is fixed to English. `secondarySub.secondarySubLanguages` and `secondarySub.autoLoadSecondarySub` apply only to local files and Jellyfin. `secondarySub.defaultMode` still controls how the secondary bar is shown. Use the picker to load a different secondary language.
-SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
-surface while it probes tracks and prepares the modal, then updates the subtitle download progress
-card to a success notification after the selected tracks load.
+Likewise, `youtube.primarySubLanguages` does not change which YouTube track is picked. It sets which languages count as a primary subtitle for local and playlist subtitle selection and for the "primary subtitle missing" notification.
-The picker displays each track with its language, kind (manual/auto), and title when available.
+## Card media
-## Subtitle format handling
-
-SubMiner handles several YouTube subtitle formats transparently:
-
-| Format | Handling |
-| ---------------------- | -------------------------------------------------------- |
-| `srt`, `vtt` | Used directly (preferred for manual tracks) |
-| `srv1`, `srv2`, `srv3` | YouTube TimedText XML - converted to VTT automatically |
-| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
-
-For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
-
-## Card media cache
-
-By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
-
-Background cache downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`; set `0` for unlimited) and use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
-
-## Configuration reference
-
-### Primary subtitle languages
+By default, card audio and screenshots are cut from mpv's live YouTube stream. If card media fails with `403` errors, switch to the background cache:
```jsonc
{
"youtube": {
- "primarySubLanguages": ["ja", "jpn"],
+ "mediaCache": { "mode": "background" },
},
}
```
-| 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. |
+In background mode, SubMiner downloads the video with yt-dlp after playback starts. Cards you mine get their text fields right away, and audio and images are added once the download finishes. `youtube.mediaCache.maxHeight` caps the download resolution (`0` for no limit). If the download fails, SubMiner tells you and drops the pending media updates.
-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.
+See [Configuration](/configuration#youtube-playback-settings) for all `youtube` options and defaults.
-### Secondary subtitle languages
+## Troubleshooting
-YouTube secondary selection is fixed: SubMiner always tries an English track (manual over auto) and loads it when found. The shared `secondarySub` config does not change YouTube track selection. `secondarySubLanguages` and `autoLoadSecondarySub` apply only to local and Jellyfin sidecar selection. `defaultMode` still controls how the loaded secondary bar is displayed:
+**No Japanese subtitles.** The video may not have any. Open the picker with `Ctrl+Alt+C` to see what is available.
-```jsonc
-{
- "secondarySub": {
- "secondarySubLanguages": [],
- "autoLoadSecondarySub": false,
- "defaultMode": "hover",
- },
-}
-```
+**yt-dlp not found.** Install it and put it on `PATH`, or set `SUBMINER_YTDLP_BIN`.
-| 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"`) |
+**Timeouts.** Each yt-dlp call times out after 15 seconds. Slow or rate-limited connections can hit this. Retry, or update yt-dlp.
-These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
+**Poor subtitle quality.** Auto-generated captions are often inaccurate. SubMiner uses a manual track when one exists.
-## Limitations and troubleshooting
+A missing or failed secondary track never blocks playback.
-- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
-- **yt-dlp not found**: Install `yt-dlp` and put it on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
-- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
-- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
-- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
-- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
-- **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.
+## Stats
-## 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)
-- [Configuration - YouTube Playback Settings](/configuration#youtube-playback-settings)
-- [Configuration - Secondary Subtitles](/configuration#secondary-subtitles)
-- [Keyboard Shortcuts](/shortcuts)
-- [Jellyfin Integration](/jellyfin-integration)
+The stats Library groups YouTube videos by channel. Choose **YouTube** in the Library filter to see them. See [Immersion tracking](/immersion-tracking).