Compare commits

..

2 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
3 changed files with 119 additions and 82 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. 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"> <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> </div>
<br> <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><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td> <td>Search and download Japanese subtitles</td>
</tr> </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> <tr>
<td><b>alass / ffsubsync</b></td> <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> <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 ## 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 | | Dependency | Status | What it does |
| -------------------- | ----------- | ---------------------------------------- | | -------------------- | ---------------- | -------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on | | 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 | | ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering | | MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback | | 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 | | alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection | | guessit | Optional | Better anime title and episode detection |
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
<details> <details>
<summary><b>Platform-specific install commands</b></summary> <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 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> </details>
@@ -166,6 +189,11 @@ paru -S subminer-bin
mkdir -p ~/.local/bin mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.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 \ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer && chmod +x ~/.local/bin/subminer
``` ```
+6 -8
View File
@@ -183,12 +183,8 @@ Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -ve
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). 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). 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. 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. Click **Environment Variables…** 4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
5. Under **User variables**, select **Path** and click **Edit…**
6. Click **New** and paste the folder containing `mpv.exe`. Click **New** again and paste the folder containing `ffmpeg.exe`.
7. Click **OK** on all three dialogs.
8. Close and reopen your terminal (PATH changes only apply to newly opened windows), then check:
```powershell ```powershell
mpv --version mpv --version
@@ -197,8 +193,10 @@ 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. 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 Don't want to touch PATH at all? ::: tip mpv can skip PATH, ffmpeg cannot
mpv is the only hard requirement, and you can point SubMiner straight at it instead: leave `PATH` alone and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup. ffmpeg still needs to be on `PATH` for card audio and screenshots. 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> </details>
@@ -5,6 +5,7 @@ import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { Database } from '../sqlite.js'; import { Database } from '../sqlite.js';
import { import {
applyPragmas,
createTrackerPreparedStatements, createTrackerPreparedStatements,
ensureSchema, ensureSchema,
getOrCreateAnimeRecord, getOrCreateAnimeRecord,
@@ -57,6 +58,16 @@ function makeDbPath(): string {
return path.join(dir, 'immersion.sqlite'); 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 { function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
@@ -172,7 +183,7 @@ function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
test('getSessionSummaries returns sessionId and canonicalTitle', () => { test('getSessionSummaries returns sessionId and canonicalTitle', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getSessionTimeline returns the full session when no limit is provided', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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)', () => { test('getDailyRollups limits by distinct days (not rows)', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -625,7 +636,7 @@ test('getDailyRollups limits by distinct days (not rows)', () => {
test('getTrendsDashboard returns chart-ready aggregated series', () => { test('getTrendsDashboard returns chart-ready aggregated series', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -779,7 +790,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => { test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -869,7 +880,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
test('getTrendsDashboard keeps local-midnight session buckets separate', () => { test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -1005,7 +1016,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => { test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => { withMockNowMs('1772395200000', () => {
try { try {
ensureSchema(db); 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', () => { test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => { withMockNowMs('1772395200000', () => {
try { try {
ensureSchema(db); ensureSchema(db);
@@ -1123,7 +1134,7 @@ test(
{ timeout: 20_000 }, { timeout: 20_000 },
() => { () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => { withMockNowMs('1772395200000', () => {
try { try {
ensureSchema(db); ensureSchema(db);
@@ -1183,7 +1194,7 @@ test(
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => { withMockNowMs('1772395200000', () => {
try { try {
ensureSchema(db); 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', () => { test('getQueryHints reads all-time totals from lifetime summary', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
withMockNowMs('1773601200000', () => { withMockNowMs('1773601200000', () => {
try { 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', () => { test('word-count read models use filtered persisted occurrences with raw fallback', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('rollups keep persisted totals when retained-session word counts are partial', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getQueryHints counts new words by distinct headword first-seen time', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getSessionSummaries with no telemetry returns zero aggregates', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -1823,7 +1834,7 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
test('getVocabularyStats returns rows ordered by frequency descending', () => { test('getVocabularyStats returns rows ordered by frequency descending', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -1866,7 +1877,7 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => { test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getVocabularyStats returns empty array when no words exist', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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 () => { test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getDailyRollups returns all rows for the most recent rollup days', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
const insertRollup = db.prepare( 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', () => { test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
const insertRollup = db.prepare( 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', () => { test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
const insertRollup = db.prepare( 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', () => { test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
const insertRollup = db.prepare( 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 () => { test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -2367,7 +2378,7 @@ test('cleanupVocabularyStats merges repaired duplicates instead of violating the
test('getKanjiStats returns rows ordered by frequency descending', () => { test('getKanjiStats returns rows ordered by frequency descending', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -2405,7 +2416,7 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
test('getKanjiStats returns empty array when no kanji exist', () => { test('getKanjiStats returns empty array when no kanji exist', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -2419,7 +2430,7 @@ test('getKanjiStats returns empty array when no kanji exist', () => {
test('getSessionEvents returns events ordered by ts_ms ascending', () => { test('getSessionEvents returns events ordered by ts_ms ascending', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getSessionEvents returns empty array for session with no events', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getSessionEvents filters events to the requested session id', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -2613,7 +2624,7 @@ test('getSessionEvents filters events to the requested session id', () => {
test('getSessionEvents respects limit parameter', () => { test('getSessionEvents respects limit parameter', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -2656,7 +2667,7 @@ test('getSessionEvents respects limit parameter', () => {
test('getSessionEvents filters by event type before applying limit', () => { test('getSessionEvents filters by event type before applying limit', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -2728,7 +2739,7 @@ test('getSessionEvents filters by event type before applying limit', () => {
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => { test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('anime-level queries group by anime_id and preserve episode-level rows', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('anime library and detail still return lifetime rows without retained sessions', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('media detail resolves retained sessions before lifetime summary exists', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('media library and detail queries read lifetime totals', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('media library and detail queries include joined youtube metadata when present', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('deleteSession removes the session and all associated session-scoped rows', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('deleteSession removes zero-session media from library and trends', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -4667,7 +4678,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => { test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
@@ -4774,7 +4785,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => { test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); 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', () => { test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = openTestDb(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);