Compare commits

...

4 Commits

Author SHA1 Message Date
sudacode 95e0abc7b7 test(immersion): apply runtime SQLite pragmas in query tests
The query test suite opened each temp database with SQLite's defaults
(rollback journal, synchronous=FULL) while the runtime opens them via
applyPragmas (WAL, synchronous=NORMAL). Every statement therefore ran as
its own fsync-ing transaction.

That is unnoticeable on a fast local disk but dominates on CI: the file
took 40.6s there versus 0.5s locally, and the 106-insert case
'getVocabularyStats pages past hidden rows' crossed the 5s per-test
timeout and failed the quality gate.

Open test databases through a helper that applies the same pragmas as
production, so the tests exercise the runtime's actual SQLite
configuration instead of a slower one.
2026-07-28 02:23:16 -07:00
sudacode d9155ceacb docs: update requirements table and Windows install instructions
- Add TsukiHime and AniSkip to feature table; note xz dependency
- Clarify Anki+AnkiConnect is required to mine, not to run
- Rework Windows setup with winget/scoop commands and PATH steps
- Note ffmpeg has no path-override setting, unlike mpv
- Fix screenshot alt text and installation.md anchor link
2026-07-28 02:10:50 -07:00
sudacode 9d0585423c chore(release): prepare v0.19.0-beta.4 2026-07-28 01:09:48 -07:00
sudacode 455cfff90a docs: fix inaccuracies and add link/anchor validation tests
- Generate config.example.jsonc with a Linux socket path instead of Windows, so the example stays reproducible across platforms
- Document TsukiHime config (`tsukihime.*`), maturity-based known-word highlighting keys, and other config surfaces missing from the reference
- Correct secondary-subtitle auto-load defaults and Anki field-matching (case-insensitive) claims
- Expand Windows installation guide with winget/Scoop package tables and manual PATH setup
- Add docs-site/links.test.ts to catch broken internal links and heading-anchor mismatches
- Fix stale cross-references in jimaku-integration.md and demos.md
2026-07-28 00:56:38 -07:00
23 changed files with 676 additions and 335 deletions
+35 -7
View File
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
<div align="center">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
</div>
<br>
@@ -90,6 +90,14 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
<td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td>
</tr>
<tr>
<td><b>TsukiHime</b></td>
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
</tr>
<tr>
<td><b>AniSkip</b></td>
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
</tr>
<tr>
<td><b>alass / ffsubsync</b></td>
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
@@ -110,18 +118,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | ---------------------------------------- |
| -------------------- | ---------------- | -------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
<details>
<summary><b>Platform-specific install commands</b></summary>
@@ -138,9 +147,23 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
brew install mpv ffmpeg mecab mecab-ipadic
```
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
**Windows:**
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
```powershell
winget install shinchiro.mpv
winget install Gyan.FFmpeg
```
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
```powershell
scoop bucket add extras
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
```
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
</details>
@@ -166,6 +189,11 @@ paru -S subminer-bin
mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage
```
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
+1 -1
View File
@@ -691,7 +691,7 @@
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
+2 -1
View File
@@ -28,7 +28,7 @@ const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin =
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
@@ -306,6 +306,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Usage', link: '/usage' },
{ text: 'Mining Workflow', link: '/mining-workflow' },
{ text: 'Launcher Script', link: '/launcher-script' },
{ text: 'Feature Demos', link: '/demos' },
],
},
{
+44 -7
View File
@@ -71,6 +71,10 @@ 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.
:::
Generate a fresh default config from the centralized config registry:
```bash
@@ -145,12 +149,13 @@ The configuration file includes several main sections:
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
**External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
@@ -538,6 +543,8 @@ Display a second subtitle track (e.g., English alongside Japanese) in the overla
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:
```json
{
"secondarySub": {
@@ -549,11 +556,15 @@ See `config.example.jsonc` for detailed configuration options.
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"`) |
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
**Display modes:**
@@ -863,8 +874,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
Current runtime options cover automatic card updates, known-word highlighting,
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
and Kiku field grouping mode.
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.
@@ -1031,6 +1042,8 @@ This example is intentionally compact. The option table below documents availabl
| `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. |
@@ -1076,6 +1089,7 @@ Known-word cache policy:
- `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.
@@ -1114,6 +1128,7 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
<source :src="withBase('/assets/kiku-integration.mp4')" type="video/mp4" />
Your browser does not support the video tag.
</video>
@@ -1139,6 +1154,28 @@ Configure Jimaku API access and defaults:
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
### 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.
```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.
### Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
@@ -1491,7 +1528,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
"executablePath": "",
"launchMode": "normal",
"profile": "",
"socketPath": "\\\\.\\pipe\\subminer-socket",
"socketPath": "/tmp/subminer-socket",
"backend": "auto",
"autoStartSubMiner": true,
"pauseUntilOverlayReady": true,
-3
View File
@@ -20,9 +20,6 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
</a>
</video>
::: info VIDEO COMING SOON
:::
## Subtitle Download & Sync
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
+31
View File
@@ -14,6 +14,10 @@ const ankiIntegrationContents = readFileSync(
'utf8',
);
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
const troubleshootingContents = readFileSync(
new URL('./troubleshooting.md', import.meta.url),
'utf8',
);
function extractReleaseHeadings(content: string, count: number): string[] {
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
@@ -58,6 +62,33 @@ test('docs reflect current launcher and release surfaces', () => {
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
});
test('docs document config surfaces that are easy to miss when they ship', () => {
// Anki maturity-based known-word highlighting (#172) landed in
// subtitle-annotations.md but was missing from the config reference.
expect(configurationContents).toContain('ankiConnect.knownWords.maturityEnabled');
expect(configurationContents).toContain('ankiConnect.knownWords.matureThresholdDays');
// Every top-level config block should be reachable from the config reference.
expect(configurationContents).toContain('### TsukiHime');
expect(configurationContents).toContain('tsukihime.maxSearchResults');
// xz is a hard runtime dependency of the TsukiHime download path.
expect(installationContents).toContain('xz');
});
test('docs state the real secondary-subtitle and Anki field-matching behavior', () => {
// secondarySub auto-load is off by default; the config example previously
// implied otherwise while youtube-integration.md documented it correctly.
expect(configurationContents).toContain('Secondary subtitles do **not** auto-load by default');
expect(configurationContents).toContain('default: `false`');
// Anki field names are matched case-insensitively (src/anki-integration.ts
// resolveFieldName: exact match first, then a lowercase comparison).
expect(usageContents).not.toContain('exactly (case-sensitive)');
expect(troubleshootingContents).not.toContain('exactly (case-sensitive)');
expect(ankiIntegrationContents).toContain('case-insensitively');
});
test('docs dev server links version navigation to local dev routes', () => {
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
expect(docsPackageContents).toContain(
+109 -17
View File
@@ -12,20 +12,23 @@ Three steps to get started:
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
| Dependency | Status | Platforms | What it does |
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | All | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | All | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | All | YouTube playback and subtitle extraction. |
| xz | Optional | All | Required for TsukiHime subtitle downloads (subtitles are served xz-compressed). Preinstalled on most Linux distros; not present on Windows by default. |
| guessit | Optional | All | Better AniSkip title/season/episode parsing. |
| alass | Optional | All | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | All | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fzf | Optional | Linux, macOS | Terminal-based video picker in the `subminer` launcher. |
| rofi | Optional | Linux | GUI-based video picker in the `subminer` launcher. |
| chafa | Optional | Linux, macOS | Thumbnail previews in the fzf picker. |
| ffmpegthumbnailer | Optional | Linux, macOS | Video thumbnail generation for the pickers. |
| fuse2 | Required | Linux | Needed to run the AppImage. |
### Linux
@@ -109,9 +112,98 @@ pip install ffsubsync
### Windows
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
No compositor tools or window helpers are needed - native window tracking is built in.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
::: tip What is PATH?
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
:::
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
| Dependency | winget | Scoop |
| ---------------- | --------------- | ------------- |
| mpv (required) | `shinchiro.mpv` | `extras/mpv` |
| ffmpeg | `Gyan.FFmpeg` | `main/ffmpeg` |
| yt-dlp (YouTube) | `yt-dlp.yt-dlp` | `main/yt-dlp` |
| xz (TsukiHime) | not packaged | `main/xz` |
Use **winget** if you want Microsoft's first-party tool and don't need TsukiHime subtitle downloads. Use **Scoop** if you want one package manager to cover everything, since it is the only one that also packages `xz`.
#### Recommended: winget
[winget](https://learn.microsoft.com/windows/package-manager/winget/) is Microsoft's own package manager and ships with Windows 11 and current Windows 10 (it comes with **App Installer** from the Microsoft Store). In **PowerShell** or **Command Prompt**:
```powershell
winget install shinchiro.mpv
winget install Gyan.FFmpeg
```
Close and reopen your terminal, then check that both are found:
```powershell
mpv --version
ffmpeg -version
```
`ffmpeg` is installed as a portable package, so winget links it into a folder that is already on your `PATH` and it should work right away.
`mpv` uses a regular installer, and depending on the version it may **not** add itself to `PATH`. If `mpv --version` says `not recognized`, you have two easy options:
- Note where it installed (usually `%LOCALAPPDATA%\Programs\mpv`) and add that folder to `PATH` using the manual steps below, or
- Skip `PATH` entirely and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup.
Once `mpv --version` works, or you have the full path to `mpv.exe` ready, continue to [step 2](#_2-install-subminer).
<details>
<summary><b>Alternative: Scoop (covers every dependency, no admin rights)</b></summary>
[Scoop](https://scoop.sh) installs into your user profile, needs no administrator prompt, and always puts commands on `PATH`. It is the only Windows package manager that carries all of SubMiner's optional dependencies, including `xz`, so it is the best choice if you want a single tool to manage everything.
```powershell
# One-time Scoop setup (skip if you already have it)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
# mpv lives in the "extras" bucket; everything else is in "main"
scoop bucket add extras
scoop install extras/mpv main/ffmpeg
# Optional: yt-dlp for YouTube playback, xz for TsukiHime subtitle downloads
scoop install main/yt-dlp main/xz
```
Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -version`.
</details>
<details>
<summary><b>Manual install (download the zips yourself)</b></summary>
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
```powershell
mpv --version
ffmpeg -version
```
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
::: tip mpv can skip PATH, ffmpeg cannot
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
:::
</details>
**Optional extras:** [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary improves annotation accuracy; it is not in any package manager, so install it from that page. `xz` is needed only for [TsukiHime](/tsukihime-integration) subtitle downloads and is not packaged by winget or Chocolatey, so use `scoop install main/xz` or download [XZ Utils](https://tukaani.org/xz/) and add its folder to `PATH`.
The `subminer` command-line launcher and its picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are Linux/macOS only; on Windows you use the **SubMiner mpv** shortcut instead.
## 2. Install SubMiner
@@ -278,7 +370,7 @@ Run the built-in diagnostic to confirm everything is working:
subminer doctor
```
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
## Anki Setup (Recommended)
+1 -1
View File
@@ -112,5 +112,5 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
## Related
- [Configuration Reference](/configuration#jimaku) - full config options
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
- [Mining Workflow](/mining-workflow#related-features) - how Jimaku fits into the sentence mining loop
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
+2 -2
View File
@@ -177,7 +177,7 @@ Use `subminer <subcommand> -h` for command-specific help.
## Options
| Flag | Description |
| --------------------- | --------------------------------------------------------------------------- |
| --------------------- | ---------------------------------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
@@ -189,7 +189,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `-b, --backend` | Force window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--settings` | Open the SubMiner settings window |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from 'bun:test';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
// Mirrors VitePress' heading slugifier (vitepress/dist/node, `rControl` + `rSpecial`).
// Note that a *run* of special characters collapses to a single `-`, so
// "KDE Plasma & other" becomes "kde-plasma-other", not "kde-plasma--other".
const rControl = new RegExp('[\\u0000-\\u001f]', 'g');
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
function slugify(heading: string): string {
return heading
.replace(rControl, '')
.replace(rSpecial, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/^(\d)/, '_$1')
.toLowerCase();
}
const EXCLUDED_PAGES = new Set(['README.md']);
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
function loadPages(): Map<string, string> {
const pages = new Map<string, string>();
for (const file of readdirSync(docsSiteDir)) {
if (!file.endsWith('.md') || EXCLUDED_PAGES.has(file)) continue;
const route = `/${file.replace(/\.md$/, '')}`;
pages.set(route, readFileSync(`${docsSiteDir}${file}`, 'utf8'));
}
return pages;
}
function anchorsFor(contents: string): Set<string> {
const anchors = new Set<string>();
for (const match of contents.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
let heading = match[1]!;
const explicitId = heading.match(/\{#([^}]+)\}\s*$/);
if (explicitId) {
anchors.add(explicitId[1]!);
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
}
anchors.add(slugify(heading.replace(/`/g, '')));
}
return anchors;
}
function resolveRoute(target: string, fromRoute: string): string {
if (target === '') return fromRoute;
if (target.startsWith('./')) return `/${target.slice(2).replace(/\.md$/, '')}`;
const normalized = target.replace(/\.md$/, '').replace(/\/$/, '');
return normalized === '' ? '/index' : normalized;
}
const pages = loadPages();
const anchors = new Map([...pages].map(([route, body]) => [route, anchorsFor(body)]));
test('every internal docs link resolves to an existing page', () => {
const broken: string[] = [];
for (const [route, body] of pages) {
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*)\)/g)) {
const link = match[1]!;
const target = link.split('#')[0]!;
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
const resolved = resolveRoute(target, route);
if (resolved !== '/index' && !pages.has(resolved)) {
broken.push(`${route.slice(1)}.md -> ${link}`);
}
}
}
expect(broken).toEqual([]);
});
test('every internal docs anchor matches a real heading slug', () => {
const broken: string[] = [];
for (const [route, body] of pages) {
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*|#[^)\s]*)\)/g)) {
const link = match[1]!;
const hashIndex = link.indexOf('#');
if (hashIndex < 0) continue;
const target = link.slice(0, hashIndex);
const anchor = link.slice(hashIndex + 1);
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
const resolved = resolveRoute(target, route);
const pageAnchors = anchors.get(resolved);
if (!pageAnchors || pageAnchors.has(anchor)) continue;
broken.push(`${route.slice(1)}.md -> ${link}`);
}
}
expect(broken).toEqual([]);
});
test('slugify matches the VitePress cases these docs actually rely on', () => {
// Regression guards for the anchors that were previously wrong.
expect(slugify('N+1 Word Highlighting')).toBe('n-1-word-highlighting');
expect(slugify('KDE Plasma & other Wayland compositors')).toBe(
'kde-plasma-other-wayland-compositors',
);
expect(slugify('Proxy Mode Setup (Yomitan / Texthooker)')).toBe(
'proxy-mode-setup-yomitan-texthooker',
);
expect(slugify('Kiku/Lapis Integration')).toBe('kiku-lapis-integration');
expect(slugify('Secondary Subtitles')).toBe('secondary-subtitles');
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
});
test('every docs page is reachable from the sidebar', async () => {
const { default: config } = await import('./.vitepress/config');
const sidebar = config.themeConfig?.sidebar as Array<{
items?: Array<{ text: string; link?: string }>;
}>;
const linked = new Set<string>();
for (const group of sidebar) {
for (const item of group.items ?? []) {
if (item.link) linked.add(item.link === '/' ? '/index' : item.link);
}
}
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
expect(orphans).toEqual([]);
});
+1 -1
View File
@@ -183,7 +183,7 @@ If you want to build your own browser client, websocket consumer, or automation
These features support the mining loop but have their own dedicated pages:
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
+1 -1
View File
@@ -8,7 +8,7 @@
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
},
"dependencies": {
"@catppuccin/vitepress": "^0.1.2",
+1 -1
View File
@@ -691,7 +691,7 @@
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
+3 -1
View File
@@ -251,7 +251,9 @@ test('dev docs version links use local targets for version route testing', async
delete process.env.SUBMINER_DOCS_CHANNEL;
delete process.env.SUBMINER_DOCS_BASE;
delete process.env.SUBMINER_DOCS_VERSION;
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
// Set explicitly (like the sibling version-nav tests) so this assertion stays
// pinned to the manifest under test instead of the config's fallback constant.
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
+104 -87
View File
@@ -16,90 +16,6 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
## Logging and App Mode
- Default log output is `warn`.
- Use `--log-level` for more/less output.
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
## Performance and Resource Impact
### At a glance
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
- Common spikes come from:
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- media sync and subtitle tooling (`alass`, `ffsubsync`)
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
### If playback feels sluggish
1. Reduce overlay workload:
- set secondary subtitles hidden:
- `secondarySub.defaultMode: "hidden"`
- disable optional enrichment:
- `subtitleStyle.enableJlpt: false`
- `subtitleStyle.frequencyDictionary.enabled: false`
2. Reduce rendering pressure:
- lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead:
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
- lower `ankiConnect.media.imageQuality`
- reduce `ankiConnect.media.maxMediaDuration`
4. Lower integration cost:
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
### Practical low-impact profile
```json
{
"subtitleStyle": {
"css": {
"font-size": "30px"
},
"enableJlpt": false,
"frequencyDictionary": {
"enabled": false
}
},
"secondarySub": {
"defaultMode": "hidden"
},
"ankiConnect": {
"media": {
"imageType": "static",
"imageQuality": 80,
"maxMediaDuration": 12
},
"ai": {
"enabled": false
}
},
"immersionTracking": {
"enabled": false
}
}
```
### If usage is still high
- Confirm only one SubMiner instance is running.
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
**"Failed to parse MPV message"**
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
@@ -148,7 +64,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
**Cards are created but fields are empty**
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
Field names in your config must name a field that exists on your Anki note type. Matching is case-insensitive (`sentenceaudio` finds `SentenceAudio`), but the spelling must otherwise match, and unknown fields are skipped silently. Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
See [Anki Integration](/anki-integration) for the full field mapping reference.
@@ -316,17 +232,115 @@ If subtitle sync fails (the error message is prefixed with the engine name):
- Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
## TsukiHime
**"xz binary not found"**
TsukiHime serves extracted subtitles xz-compressed, so SubMiner shells out to `xz` to decompress them. Install it:
- **Arch Linux**: `sudo pacman -S xz`
- **Ubuntu/Debian**: `sudo apt install xz-utils`
- **Fedora**: `sudo dnf install xz`
- **macOS**: `brew install xz`
- **Windows**: neither winget nor Chocolatey packages `xz`. Use `scoop install main/xz`, or download XZ Utils from [tukaani.org/xz](https://tukaani.org/xz/) and add the folder containing `xz.exe` to your `PATH`. Restart SubMiner afterwards.
Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime-integration#troubleshooting) for the other TsukiHime error messages.
## Jimaku
**"Jimaku request failed" or HTTP 429**
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
## Logging and App Mode
- Default log output is `warn`.
- Use `--log-level` for more/less output.
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
## Performance and Resource Impact
### At a glance
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
- Common spikes come from:
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- media sync and subtitle tooling (`alass`, `ffsubsync`)
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
### If playback feels sluggish
1. Reduce overlay workload:
- set secondary subtitles hidden:
- `secondarySub.defaultMode: "hidden"`
- disable optional enrichment:
- `subtitleStyle.enableJlpt: false`
- `subtitleStyle.frequencyDictionary.enabled: false`
2. Reduce rendering pressure:
- lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead:
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
- lower `ankiConnect.media.imageQuality`
- reduce `ankiConnect.media.maxMediaDuration`
4. Lower integration cost:
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
### Practical low-impact profile
```json
{
"subtitleStyle": {
"css": {
"font-size": "30px"
},
"enableJlpt": false,
"frequencyDictionary": {
"enabled": false
}
},
"secondarySub": {
"defaultMode": "hidden"
},
"ankiConnect": {
"media": {
"imageType": "static",
"imageQuality": 80,
"maxMediaDuration": 12
},
"ai": {
"enabled": false
}
},
"immersionTracking": {
"enabled": false
}
}
```
### If usage is still high
- Confirm only one SubMiner instance is running.
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
## Platform-Specific
### Linux
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
@@ -428,8 +442,11 @@ Feature-specific issues are covered in each feature's own page:
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
- [TsukiHime Integration](/tsukihime-integration) - multi-language subtitle download and `xz` decompression
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
- [Immersion Tracking](/immersion-tracking) - telemetry, session logging, and the stats dashboard
- [Launcher Script](/launcher-script) - `subminer` commands, pickers, watch history, and cross-machine sync
- [MPV Plugin](/mpv-plugin) - in-player chords, script-opts, and binary auto-detection
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
+92 -113
View File
@@ -33,7 +33,7 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
}
```
Field names must match your Anki note type exactly (case-sensitive). See [Anki Integration](/anki-integration) for the full reference.
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
:::
## How It Works
@@ -56,126 +56,90 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings include:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `subtitleSidebar`
- `notifications`
- `logging`
- `jimaku`, `subsync`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
- `stats.toggleKey`, `stats.markWatchedKey`
- `youtube.primarySubLanguages`
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Commands
On Windows, replace `SubMiner.AppImage` with `SubMiner.exe` in the direct packaged-app examples below.
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
```bash
# Browse and play videos
subminer # Current directory (uses fzf)
subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file (overlay auto-starts)
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
subminer video.mkv # Play a specific file
subminer # Browse the current directory (fzf picker)
subminer -R # Browse with the rofi picker instead
subminer -d ~/Anime -r # Browse a specific directory, recursively
subminer -H # Browse watch history, then replay/next/previous
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
subminer -H # Browse history, then choose previous/replay/next after playback
subminer app --setup # Open first-run setup popup
subminer --version # Print the launcher's version
subminer -v # Same as above
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
subminer --log-level warn video.mkv # Set logging level explicitly
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
subminer stats # Open the immersion stats dashboard
subminer doctor # Check dependencies, config, and the mpv socket
subminer settings # Open the SubMiner settings window
subminer app --setup # Re-open first-run setup
subminer -u # Check for updates
```
# Options
subminer -T video.mkv # Disable texthooker server
subminer -b x11 video.mkv # Force X11 backend
subminer video.mkv # No mpv profile passed by default
On **Windows** there is no `subminer` launcher. Use the **SubMiner mpv** shortcut for playback (see [Windows mpv Shortcut](#windows-mpv-shortcut)), and run `SubMiner.exe` directly for everything else.
Two flags are worth knowing early:
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
- `--log-level debug` turns on verbose logging when something is not working.
<details>
<summary><b>Less common launcher commands</b></summary>
```bash
subminer --start video.mkv # Explicit overlay start (when mpv.autoStartSubMiner is false)
subminer -S video.mkv # Also force the visible overlay on start
subminer -T video.mkv # Disable the texthooker server
subminer -b x11 video.mkv # Force a window backend
subminer -p gpu-hq video.mkv # Use a specific mpv profile
subminer jellyfin # Open Jellyfin setup window (subcommand form)
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
subminer jellyfin --logout # Clear stored Jellyfin token/session data
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
subminer doctor # Dependency + config + socket diagnostics
subminer logs -e # Export a sanitized log ZIP and print its path
subminer config path # Print active config path
subminer config show # Print active config contents
subminer mpv socket # Print active mpv socket path
subminer mpv status # Exit 0 if socket is ready, else exit 1
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
subminer sync media-box # Sync stats/watch history with an SSH host
subminer sync media-box --push # Merge this machine's stats into the host only
subminer sync media-box --pull # Merge the host's stats into this machine only
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
subminer sync media-box --json # Emit machine-readable NDJSON progress
subminer sync --ui # Open the Sync Stats & History window
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
subminer sync --make-temp # Create an internal sync temp directory
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv
subminer texthooker # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser
subminer stats # Start the local stats server (see Immersion Tracking)
subminer ytsearch:"jp news" # Play the first YouTube search result
subminer texthooker # Texthooker-only mode (-o also opens the browser)
subminer stats -b # Start/reuse the background stats daemon
subminer stats -s # Stop the background stats daemon
subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
subminer stats rebuild # Rebuild rollup data
subminer doctor --refresh-known-words # Refresh the known-word cache
subminer logs -e # Export a sanitized log ZIP and print its path
subminer config path # Print the active config path
subminer config show # Print the active config contents
subminer mpv socket # Print the active mpv socket path
subminer mpv status # Exit 0 if the socket is ready, else exit 1
subminer mpv idle # Launch a detached idle mpv with SubMiner defaults
subminer app --stop # Stop the background app
subminer --version # Print the launcher's version
```
# Direct packaged app control
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
</details>
<details>
<summary><b>Direct packaged-app flags (advanced)</b></summary>
These call the app binary directly rather than going through the launcher. On Windows, replace `SubMiner.AppImage` with `SubMiner.exe`.
```bash
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
SubMiner.AppImage --setup # Open first-run setup popup
SubMiner.AppImage --texthooker # Texthooker only (no overlay window)
SubMiner.AppImage --setup # Open first-run setup
SubMiner.AppImage --stop # Stop overlay
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --start --toggle # Start mpv IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show the visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide the visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle bar
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open SubMiner settings window
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
SubMiner.AppImage --jellyfin-libraries
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
SubMiner.AppImage --start --dev # Enable app/dev mode
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
SubMiner.AppImage --help # Show all options
```
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
</details>
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
@@ -217,18 +181,11 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
### Launcher Subcommands
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
- `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
- Subcommand help pages are available (for example `subminer jellyfin -h`).
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
Every subcommand has its own help page, for example `subminer jellyfin -h`. See [Launcher Script - Subcommands](/launcher-script#subcommands) for the full table, and [Sync Between Machines](/launcher-script#sync-between-machines) for the SSH stats/history sync.
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
### First-Run Setup
@@ -325,6 +282,28 @@ Notes:
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings include:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `subtitleSidebar`
- `notifications`
- `logging`
- `jimaku`, `subsync`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
- `stats.toggleKey`, `stats.markWatchedKey`
- `youtube.primarySubLanguages`
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Controller Support
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
+1 -1
View File
@@ -377,4 +377,4 @@ ws.on('message', async (raw) => {
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
- [MPV Plugin](/mpv-plugin)
- [Launcher Script](/launcher-script)
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
+1 -1
View File
@@ -153,6 +153,6 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
- [Usage --- YouTube Playback](/usage#youtube-playback)
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
- [Configuration --- Secondary Subtitle](/configuration#secondary-subtitle)
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
- [Keyboard Shortcuts](/shortcuts)
- [Jellyfin Integration](/jellyfin-integration)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.3",
"version": "0.19.0-beta.4",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+14
View File
@@ -17,6 +17,12 @@
- **Post-Playback History Menu**
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
- **Known-Word Highlighting by Anki Maturity**
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
### Changed
@@ -28,6 +34,9 @@
- **Word Highlighting Accuracy**
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
- **Character Dictionary Season Overrides**
- Manual AniList overrides for a series now stay in effect for every episode in the same season folder, even when individual episode filenames produce different automatic guesses.
- **Startup Playback Pausing Too Early**
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
@@ -40,9 +49,13 @@
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
- **Stats & Settings Reliability**
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
- **Rofi Menu Prompt Spacing**
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
@@ -50,6 +63,7 @@
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
- Anki maturity-based known-word highlighting by @ksyasuda in #172
## Installation
@@ -5,6 +5,7 @@ import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import {
applyPragmas,
createTrackerPreparedStatements,
ensureSchema,
getOrCreateAnimeRecord,
@@ -57,6 +58,16 @@ function makeDbPath(): string {
return path.join(dir, 'immersion.sqlite');
}
// Match the runtime's SQLite tuning (WAL + synchronous=NORMAL). Without it these
// tests run at SQLite's defaults, where every statement is its own fsync-ing
// transaction; on slow CI disks that is enough to push insert-heavy cases past
// the 5s per-test timeout.
function openTestDb(dbPath: string) {
const db = new Database(dbPath);
applyPragmas(db);
return db;
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
@@ -172,7 +183,7 @@ function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -231,7 +242,7 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -299,7 +310,7 @@ test('getAnimeEpisodes prefers the latest session media position when the latest
test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -397,7 +408,7 @@ test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -469,7 +480,7 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -541,7 +552,7 @@ test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to
test('getSessionTimeline returns the full session when no limit is provided', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -592,7 +603,7 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
test('getDailyRollups limits by distinct days (not rows)', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -625,7 +636,7 @@ test('getDailyRollups limits by distinct days (not rows)', () => {
test('getTrendsDashboard returns chart-ready aggregated series', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -779,7 +790,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -869,7 +880,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1005,7 +1016,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1069,7 +1080,7 @@ test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1123,7 +1134,7 @@ test(
{ timeout: 20_000 },
() => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1183,7 +1194,7 @@ test(
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1406,7 +1417,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
test('getQueryHints reads all-time totals from lifetime summary', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1476,7 +1487,7 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1773601200000', () => {
try {
@@ -1528,7 +1539,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
test('word-count read models use filtered persisted occurrences with raw fallback', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1611,7 +1622,7 @@ test('word-count read models use filtered persisted occurrences with raw fallbac
test('rollups keep persisted totals when retained-session word counts are partial', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1672,7 +1683,7 @@ test('rollups keep persisted totals when retained-session word counts are partia
test('getQueryHints counts new words by distinct headword first-seen time', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1737,7 +1748,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
test('getSessionSummaries with no telemetry returns zero aggregates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1771,7 +1782,7 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1823,7 +1834,7 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
test('getVocabularyStats returns rows ordered by frequency descending', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1866,7 +1877,7 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1921,7 +1932,7 @@ test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', ()
test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1956,7 +1967,7 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
test('getVocabularyStats returns empty array when no words exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1970,7 +1981,7 @@ test('getVocabularyStats returns empty array when no words exist', () => {
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2100,7 +2111,7 @@ test('cleanupVocabularyStats repairs stored POS metadata and removes excluded im
test('getDailyRollups returns all rows for the most recent rollup days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2136,7 +2147,7 @@ test('getDailyRollups returns all rows for the most recent rollup days', () => {
test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2167,7 +2178,7 @@ test('getMonthlyRollups returns all rows for the most recent rollup months', ()
test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2198,7 +2209,7 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2254,7 +2265,7 @@ test('getAnimeDailyRollups returns all rows for the most recent rollup days', ()
test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2367,7 +2378,7 @@ test('cleanupVocabularyStats merges repaired duplicates instead of violating the
test('getKanjiStats returns rows ordered by frequency descending', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2405,7 +2416,7 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
test('getKanjiStats returns empty array when no kanji exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2419,7 +2430,7 @@ test('getKanjiStats returns empty array when no kanji exist', () => {
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2480,7 +2491,7 @@ test('getSessionEvents returns events ordered by ts_ms ascending', () => {
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2524,7 +2535,7 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
test('getSessionEvents returns empty array for session with no events', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2547,7 +2558,7 @@ test('getSessionEvents returns empty array for session with no events', () => {
test('getSessionEvents filters events to the requested session id', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2613,7 +2624,7 @@ test('getSessionEvents filters events to the requested session id', () => {
test('getSessionEvents respects limit parameter', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2656,7 +2667,7 @@ test('getSessionEvents respects limit parameter', () => {
test('getSessionEvents filters by event type before applying limit', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2728,7 +2739,7 @@ test('getSessionEvents filters by event type before applying limit', () => {
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2803,7 +2814,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
test('anime-level queries group by anime_id and preserve episode-level rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3083,7 +3094,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
test('anime library and detail still return lifetime rows without retained sessions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3180,7 +3191,7 @@ test('anime library and detail still return lifetime rows without retained sessi
test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3270,7 +3281,7 @@ test('anime and media detail prefer lifetime totals over partial retained sessio
test('media detail resolves retained sessions before lifetime summary exists', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3323,7 +3334,7 @@ test('media detail resolves retained sessions before lifetime summary exists', (
test('media library and detail queries read lifetime totals', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3415,7 +3426,7 @@ test('media library and detail queries read lifetime totals', () => {
test('media library and detail queries include joined youtube metadata when present', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3512,7 +3523,7 @@ test('media library and detail queries include joined youtube metadata when pres
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3639,7 +3650,7 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3685,7 +3696,7 @@ test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', (
test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3827,7 +3838,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3902,7 +3913,7 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3999,7 +4010,7 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4083,7 +4094,7 @@ test('searchSubtitleSentences searches subtitle lines by resolved headword candi
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4158,7 +4169,7 @@ test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line c
test('deleteSession removes the session and all associated session-scoped rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4306,7 +4317,7 @@ test('deleteSession removes the session and all associated session-scoped rows',
test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4462,7 +4473,7 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
test('deleteSession removes zero-session media from library and trends', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4667,7 +4678,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4774,7 +4785,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4856,7 +4867,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4971,7 +4982,7 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
+1 -1
View File
@@ -85,5 +85,5 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
const template = generateConfigExampleTemplate();
assert.match(template, /"socketPath": "\\\\\\\\.\\\\pipe\\\\subminer-socket"/);
assert.match(template, /"socketPath": "\/tmp\/subminer-socket"/);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import * as path from 'path';
import { DEFAULT_CONFIG, deepCloneConfig, generateConfigTemplate } from './config';
import { getDefaultMpvSocketPath } from './shared/mpv-socket-path';
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'win32';
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'linux';
export function generateConfigExampleTemplate(): string {
const config = deepCloneConfig(DEFAULT_CONFIG);