Compare commits

..

5 Commits

Author SHA1 Message Date
sudacode e876e483d6 chore(release): prepare v0.19.0-beta.5 2026-07-29 02:09:01 -07:00
sudacode 6d2a72e13b feat(stats): add library entry deletion and app-wide delete progress (#174) 2026-07-29 02:00:16 -07:00
sudacode 0d7084c8aa fix(anilist): resolve later seasons via sequel relations, not title guessing (#173) 2026-07-28 22:56:26 -07:00
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
65 changed files with 4133 additions and 647 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
```
+8
View File
@@ -0,0 +1,8 @@
type: fixed
area: anilist
- Season 2 and later files now resolve to the right AniList entry. AniList has no numbered seasons (sequels are separate entries titled `Zoku`, `Kan`, `2nd Season`), so the old `<title> Season N` search returned nothing and silently fell back to the season 1 entry: an Oregairu S03 episode loaded the season 1 character dictionary and pushed watch progress onto season 1. SubMiner now searches the base title and walks AniList `SEQUEL` relations to reach the requested season, ordering the franchise's TV entries by air date when the relation chain is incomplete.
- When the season still cannot be located, SubMiner no longer writes progress to the season 1 entry; it skips the update and points at the manual AniList override instead. The character dictionary logs the same warning and states which entry it fell back to.
- Character dictionary overrides are now scoped by detected season as well as directory, so seasons kept in one flat folder no longer share (or overwrite) each other's override, and an override now also pins the entry used for AniList watch progress: correcting a wrong match once fixes both the dictionary and progress tracking.
- Cover art lookup uses the same season-aware resolution, so per-season art no longer falls back to the season 1 cover.
- An unresolvable season is no longer cached as if it were a normal match, so the fallback cannot silently stick for every later episode of that series.
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Relinking a title to a different AniList entry now updates its cover in the stats Library grid too. The detail view picked up the new art immediately while the grid kept the previous entry's image, so several unrelated titles could end up sharing one wrong cover. The library list is refetched after a relink, and cover responses now carry an ETag and revalidate instead of being cached for a day, so a stale image can no longer be served from the browser cache.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: stats
- Added a "Delete Entry" action to the stats Library detail view. It removes the whole title in one step — every episode, session, subtitle line, rollup, cover art and vocabulary counts derived from them — and the title disappears from the Library grid. Previously only individual episodes and sessions could be deleted, so a mistakenly created entry had to be cleared episode by episode and still lingered in the library.
- Deletion progress is now shown app-wide instead of only on Overview and Sessions. Every delete (session, session group, episode, and library entry) drives a sweeping bar at the top of the window plus a bottom-right status toast, and both stay visible across tab switches, detail views, and the in-app stats overlay window. The per-tab indicators previously went blank the moment you left the tab that started the delete.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: stats
- Made deleting stats data much faster and stopped it stalling playback. Every delete used to recompute each affected word's totals by re-reading that word's whole occurrence history across the library, which meant one random read into the largest table per occurrence. On a 960-session library that cost 3.8s to delete a single session and over a minute to clear a 12-episode title, and because the stats server runs in the app process when the app owns it, that time was spent blocking mpv.
- Word and kanji occurrences now store the subtitle line's timestamp alongside the count, so those totals are answered from a covering index instead of the subtitle-line table, and a delete only subtracts what it actually removed rather than recomputing untouched entries. Measured on the same 960-session library: deleting one session 3832ms to 190ms, a ten-session day group 6453ms to 433ms, one episode 5437ms to 263ms, and a whole 12-episode title 60556ms to 621ms.
- Opening the Vocabulary tab no longer stalls either. It was computing "seen in N titles" for every word in the library before ordering by frequency and keeping the top 100, so the whole occurrence history was walked to produce one page. It now picks the page first and counts only those rows: 2136ms to 62ms on the same library.
- The first launch after upgrading migrates the existing stats database in place (about 3s for a 288k-line library) and it grows roughly 20% from the added index. Vocabulary dates recorded before the upgrade keep working while the migration runs.
+4 -2
View File
@@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
@@ -69,6 +69,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
## Rate Limiting
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
@@ -94,7 +96,7 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
```
| Option | Values | Description |
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
+3 -1
View File
@@ -223,7 +223,9 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
```
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory defines the override scope, so later episodes in the same season directory keep the selected AniList ID even if their filename guesses differ. Separate season directories can keep separate overrides and character dictionaries. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
## Managing Loaded Entries
+2
View File
@@ -57,6 +57,8 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
![Stats Library](/screenshots/stats-library.png)
#### Trends
+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).
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.
4. Click **Environment Variables…**
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:
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
@@ -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.
::: tip Don't want to touch PATH at all?
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.
::: 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>
+4 -2
View File
@@ -185,6 +185,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -193,6 +194,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -313,8 +315,8 @@ export const IMMERSION_DB_FIXTURE_DDL = `
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
CREATE INDEX idx_word_line_occurrences_word_seen ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id);
CREATE INDEX idx_kanji_line_occurrences_kanji_seen ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id);
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.4",
"version": "0.19.0-beta.5",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+13 -2
View File
@@ -24,6 +24,11 @@
- 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.
- **Stats Library Entry Deletion**
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
### Changed
- **Clipboard-Video Shortcut**
@@ -34,8 +39,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.
- **AniList Season Resolution**
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
- **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.
@@ -53,6 +59,9 @@
- 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.
- **Stats Library Cover After Relink**
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
- **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.
@@ -64,6 +73,8 @@
- 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
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
## Installation
@@ -1100,12 +1100,56 @@ describe('stats server API routes', () => {
assert.deepEqual(assignments, [{ animeId: 1, body }]);
});
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
it('GET /api/stats/anime/:animeId/cover returns revalidated cover art', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1/cover');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/jpeg');
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');
assert.equal(res.headers.get('cache-control'), 'no-cache');
assert.ok(res.headers.get('etag'));
});
it('GET /api/stats/anime/:animeId/cover answers 304 for a matching ETag', async () => {
const app = createStatsApp(createMockTracker());
const first = await app.request('/api/stats/anime/1/cover');
const etag = first.headers.get('etag');
assert.ok(etag);
const cached = await app.request('/api/stats/anime/1/cover', {
headers: { 'If-None-Match': `W/${etag}, "other"` },
});
assert.equal(cached.status, 304);
assert.equal(cached.headers.get('etag'), etag);
});
it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => {
// A relinked AniList entry swaps the bytes behind the same cover URL.
let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
const app = createStatsApp(
createMockTracker({
getAnimeCoverArt: async () => ({
videoId: 1,
anilistId: 21858,
coverUrl: 'https://example.com/cover.jpg',
coverBlob,
titleRomaji: 'Little Witch Academia',
titleEnglish: 'Little Witch Academia',
episodesTotal: 25,
fetchedAtMs: Date.now(),
}),
}),
);
const first = await app.request('/api/stats/anime/1/cover');
const staleEtag = first.headers.get('etag');
assert.ok(staleEtag);
coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const res = await app.request('/api/stats/anime/1/cover', {
headers: { 'If-None-Match': staleEtag },
});
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/png');
assert.notEqual(res.headers.get('etag'), staleEtag);
});
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
@@ -2873,6 +2917,39 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
let deletedAnimeId: number | null = null;
const app = createStatsApp(
createMockTracker({
deleteAnime: async (animeId: number) => {
deletedAnimeId = animeId;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });
assert.equal(res.status, 200);
assert.equal(deletedAnimeId, 7);
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
let deleteCalls = 0;
const app = createStatsApp(
createMockTracker({
deleteAnime: async () => {
deleteCalls += 1;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });
assert.equal(res.status, 400);
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
@@ -143,3 +143,50 @@ test('anilist update queue persists and reloads from disk', () => {
});
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
});
test('drops queued items whose persisted mediaId is not a positive integer', () => {
const filePath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'anilist-queue-mediaid-')),
'queue.json',
);
const base = {
episode: 1,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 0,
lastError: null,
};
fs.writeFileSync(
filePath,
JSON.stringify({
pending: [
{ ...base, key: 'ok-absent', title: 'A' },
{ ...base, key: 'ok-null', title: 'B', mediaId: null },
{ ...base, key: 'ok-valid', title: 'C', mediaId: 108489 },
{ ...base, key: 'bad-string', title: 'D', mediaId: '108489' },
{ ...base, key: 'bad-zero', title: 'E', mediaId: 0 },
{ ...base, key: 'bad-negative', title: 'F', mediaId: -3 },
{ ...base, key: 'bad-float', title: 'G', mediaId: 1.5 },
{ ...base, key: 'bad-object', title: 'H', mediaId: { id: 1 } },
],
deadLetter: [],
}),
'utf-8',
);
const queue = createAnilistUpdateQueue(filePath, {
info: () => {},
warn: () => {},
error: () => {},
});
const keptKeys: string[] = [];
for (let i = 0; i < 8; i += 1) {
const next = queue.nextReady(1000);
if (!next) break;
keptKeys.push(next.key);
queue.markSuccess(next.key);
}
assert.deepEqual(keptKeys, ['ok-absent', 'ok-null', 'ok-valid']);
});
@@ -10,6 +10,8 @@ export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
/** Pinned AniList media id from a manual override, when one applied at enqueue time. */
mediaId?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -29,13 +31,31 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
enqueue: (
key: string,
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
}
/**
* A persisted mediaId pins the update to a specific AniList entry, so a corrupted or
* hand-edited queue file must not be able to feed a bogus id straight to the mutation.
*/
function isValidPersistedMediaId(value: unknown): boolean {
return (
value === undefined ||
value === null ||
(typeof value === 'number' && Number.isInteger(value) && value > 0)
);
}
function clampBackoffMs(attemptCount: number): number {
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed);
@@ -82,6 +102,7 @@ export function createAnilistUpdateQueue(
typeof item.createdAt === 'number' &&
typeof item.attemptCount === 'number' &&
typeof item.nextAttemptAt === 'number' &&
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.slice(0, MAX_ITEMS);
@@ -96,6 +117,7 @@ export function createAnilistUpdateQueue(
typeof item.createdAt === 'number' &&
typeof item.attemptCount === 'number' &&
typeof item.nextAttemptAt === 'number' &&
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.slice(0, MAX_ITEMS);
@@ -107,7 +129,13 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
enqueue(
key: string,
title: string,
episode: number,
season: number | null = null,
mediaId: number | null = null,
): void {
const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) {
@@ -120,6 +148,7 @@ export function createAnilistUpdateQueue(
key,
title,
season,
mediaId,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -376,7 +376,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
}
});
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
test('updateAnilistPostWatchProgress resolves later seasons through the sequel chain', async () => {
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
let call = 0;
@@ -389,21 +389,45 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
data: {
Page: {
media: [
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } },
{ id: 202, episodes: 12, format: 'TV', title: { english: 'Demo Show Sequel' } },
],
},
},
});
}
if (call === 2) {
assert.equal(body.variables?.mediaId, 202);
assert.equal(body.variables?.id, 101);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: null },
Media: {
relations: {
edges: [
{
relationType: 'SEQUEL',
node: {
id: 202,
type: 'ANIME',
episodes: 12,
format: 'TV',
title: { english: 'Demo Show Sequel' },
},
},
],
},
},
},
});
}
if (call === 3) {
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: { progress: 1, status: 'CURRENT' } },
},
});
}
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
@@ -415,16 +439,118 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
season: 2,
});
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
// Searches the bare title: AniList has no "Demo Show Season 2" entry to find.
assert.deepEqual(searchTerms, ['Demo Show']);
assert.equal(result.status, 'updated');
assert.match(result.message, /Demo Show Sequel/);
assert.equal(call, 4);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress refuses to update season 1 when the season is unresolvable', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }],
},
},
});
}
return createJsonResponse({ data: { Media: { relations: { edges: [] } } } });
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, { season: 3 });
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.match(result.message, /could not find season 3/i);
// Stops after search + relation lookup: no entry lookup, no save.
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress uses a pinned media id without searching', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
assert.equal(body.variables?.mediaId, 108489);
return createJsonResponse({
data: {
Media: {
id: 108489,
episodes: 12,
title: { english: 'Pinned Show' },
mediaListEntry: { progress: 4, status: 'CURRENT' },
},
},
});
}
assert.equal(body.variables?.mediaId, 108489);
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 5, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 5, {
season: 3,
mediaId: 108489,
});
assert.equal(result.status, 'updated');
assert.match(result.message, /Pinned Show/);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress marks the final episode completed for a pinned media id', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
return createJsonResponse({
data: {
Media: {
id: 108489,
episodes: 12,
title: { english: 'Pinned Show' },
mediaListEntry: { progress: 11, status: 'CURRENT' },
},
},
});
}
assert.equal(body.variables?.status, 'COMPLETED');
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Pinned Show', 12, {
mediaId: 108489,
});
assert.equal(result.status, 'updated');
assert.match(result.message, /completed/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
@@ -479,3 +605,33 @@ test('updateAnilistPostWatchProgress returns error when search fails', async ()
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not requeue a search that matched nothing', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
createJsonResponse({ data: { Page: { media: [] } } })) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Unknown Show', 3);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /no matches/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress still allows retry when the search itself fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
createJsonResponse({ errors: [{ message: 'upstream exploded' }] })) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
assert.equal(result.status, 'error');
assert.notEqual(result.retryable, false);
assert.match(result.message, /search failed/i);
} finally {
globalThis.fetch = originalFetch;
}
});
+94 -117
View File
@@ -3,6 +3,7 @@ import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import type { AnilistRateLimiter } from './rate-limiter';
import { resolveAnilistSeasonMedia } from './season-resolver';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -24,6 +25,12 @@ export interface AnilistPostWatchUpdateResult {
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
/**
* Pinned AniList media id (from a character dictionary manual override). When set,
* the search/season resolution is skipped entirely.
*/
mediaId?: number | null;
logInfo?: (message: string) => void;
}
interface AnilistGraphQlError {
@@ -35,23 +42,15 @@ interface AnilistGraphQlResponse<T> {
errors?: AnilistGraphQlError[];
}
interface AnilistSearchData {
Page?: {
media?: Array<{
interface AnilistMediaEntryData {
Media?: {
id: number;
episodes: number | null;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>;
};
}
interface AnilistMediaEntryData {
Media?: {
id: number;
} | null;
mediaListEntry?: {
progress?: number | null;
status?: string | null;
@@ -154,32 +153,6 @@ function buildGuessitTitle(title: string, alternativeTitle: string | null): stri
return title;
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function titleMentionsSeason(title: string, season: number): boolean {
const normalized = normalizeTitle(title);
return (
normalized.includes(`season ${season}`) ||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
normalized.includes(`s${season}`)
);
}
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const candidates =
typeof season === 'number' &&
Number.isInteger(season) &&
season > 1 &&
!titleMentionsSeason(trimmed, season)
? [`${trimmed} Season ${season}`, trimmed]
: [trimmed];
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
@@ -216,38 +189,22 @@ function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | nul
return firstError?.message ?? null;
}
function pickBestSearchResult(
title: string,
episode: number,
media: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
/** The season resolver signals failure by throwing; anilistGraphQl reports it in-band. */
function createAnilistSeasonQueryExecutor(
accessToken: string,
options: AnilistPostWatchUpdateOptions,
) {
return async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
const response = await anilistGraphQl<T>(accessToken, query, variables, options);
const error = firstErrorMessage(response);
if (error) {
throw new Error(error);
}
if (!response.data) {
throw new Error('AniList response missing data');
}
return response.data;
};
}>,
): { id: number; title: string; episodes: number | null } | null {
const filtered = media.filter((item) => {
const totalEpisodes = item.episodes;
return totalEpisodes === null || totalEpisodes >= episode;
});
const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) return null;
const normalizedTarget = normalizeTitle(title);
const exact = candidates.find((item) => {
const titles = [item.title?.romaji, item.title?.english, item.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
return titles.includes(normalizedTarget);
});
const selected = exact ?? candidates[0]!;
const selectedTitle =
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
}
function isUpdateableListStatus(status: string | null | undefined): boolean {
@@ -321,52 +278,53 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
let searchError: string | null = null;
let pickTitle = title;
const searchCandidates = buildSearchCandidates(title, options.season);
for (const search of searchCandidates) {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
{ search },
options,
);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
const pinnedMediaId =
typeof options.mediaId === 'number' && Number.isInteger(options.mediaId) && options.mediaId > 0
? options.mediaId
: null;
if (searchError) {
let mediaId = pinnedMediaId;
let resolvedTitle: string | null = null;
let resolvedEpisodes: number | null = null;
if (mediaId === null) {
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
try {
resolution = await resolveAnilistSeasonMedia(
{ title, season: options.season, episode },
{
execute: createAnilistSeasonQueryExecutor(accessToken, options),
logInfo: options.logInfo,
},
);
} catch (error) {
return {
status: 'error',
message: `AniList search failed: ${searchError}`,
message: `AniList search failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const picked = pickBestSearchResult(pickTitle, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
if (!resolution) {
// A well-formed search that matched nothing is deterministic for this title, so
// requeueing it just burns rate limit. Transient failures throw and are caught above.
return {
status: 'error',
retryable: false,
message: 'AniList search returned no matches.',
};
}
if (!resolution.seasonResolved) {
// Updating the season 1 entry here is worse than not updating at all.
return {
status: 'error',
retryable: false,
message: `AniList update skipped: could not find season ${resolution.requestedSeason} of "${title}" (only matched "${resolution.title}"). Pick the right entry with the character dictionary AniList override.`,
};
}
mediaId = resolution.id;
resolvedTitle = resolution.title;
resolvedEpisodes = resolution.episodes;
}
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
@@ -375,6 +333,12 @@ export async function updateAnilistPostWatchProgress(
query ($mediaId: Int!) {
Media(id: $mediaId, type: ANIME) {
id
episodes
title {
romaji
english
native
}
mediaListEntry {
progress
status
@@ -382,7 +346,7 @@ export async function updateAnilistPostWatchProgress(
}
}
`,
{ mediaId: picked.id },
{ mediaId },
options,
);
const entryError = firstErrorMessage(entryResponse);
@@ -393,21 +357,34 @@ export async function updateAnilistPostWatchProgress(
};
}
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
const entryMedia = entryResponse.data?.Media ?? null;
const pickedTitle =
resolvedTitle ||
entryMedia?.title?.english?.trim() ||
entryMedia?.title?.romaji?.trim() ||
entryMedia?.title?.native?.trim() ||
title;
const pickedEpisodes =
resolvedEpisodes ??
(typeof entryMedia?.episodes === 'number' && entryMedia.episodes > 0
? entryMedia.episodes
: null);
const entry = entryMedia?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
return {
status: 'error',
retryable: false,
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
message: `AniList update not possible: "${pickedTitle}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
};
}
const currentProgress = entry.progress ?? 0;
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
return {
status: 'skipped',
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
message: `AniList already at episode ${currentProgress} (${pickedTitle}).`,
};
}
@@ -422,7 +399,7 @@ export async function updateAnilistPostWatchProgress(
}
`,
{
mediaId: picked.id,
mediaId,
progress: episode,
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
},
@@ -436,7 +413,7 @@ export async function updateAnilistPostWatchProgress(
return {
status: 'updated',
message: shouldMarkCompleted
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
: `AniList updated "${picked.title}" to episode ${episode}.`,
? `AniList updated "${pickedTitle}" to episode ${episode} and marked it completed.`
: `AniList updated "${pickedTitle}" to episode ${episode}.`,
};
}
@@ -201,17 +201,45 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
});
const searchCalls: Array<{ search: string }> = [];
const relationCalls: number[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const raw = (init?.body as string | undefined) ?? '';
const payload = JSON.parse(raw) as { variables: { search: string } };
const search = payload.variables.search;
searchCalls.push({ search });
const payload = JSON.parse(raw) as { variables: { search?: string; id?: number } };
if (search.includes('Season 2')) {
return Promise.resolve(createJsonResponse({ data: { Page: { media: [] } } }));
if (typeof payload.variables.id === 'number') {
relationCalls.push(payload.variables.id);
return Promise.resolve(
createJsonResponse({
data: {
Media: {
relations: {
edges: [
{
relationType: 'SEQUEL',
node: {
id: 20,
type: 'ANIME',
episodes: 25,
format: 'TV',
seasonYear: 2017,
coverImage: { large: 'https://images.test/cover-s2.jpg', medium: null },
title: {
romaji: 'Little Witch Academia 2',
english: 'Little Witch Academia 2',
native: null,
},
},
},
],
},
},
},
}),
);
}
searchCalls.push({ search: String(payload.variables.search) });
return Promise.resolve(
createJsonResponse({
data: {
@@ -220,6 +248,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
{
id: 19,
episodes: 24,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
title: {
romaji: 'Little Witch Academia',
@@ -251,9 +281,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
const stored = getCoverArt(db, videoId);
assert.equal(fetched, true);
assert.equal(searchCalls.length, 2);
assert.equal(searchCalls[0]!.search, 'Little Witch Academia Season 2');
assert.equal(stored?.anilistId, 19);
// One search on the bare title, then a sequel hop to reach season 2.
assert.equal(searchCalls.length, 1);
assert.equal(searchCalls[0]!.search, 'Little Witch Academia');
assert.deepEqual(relationCalls, [19]);
assert.equal(stored?.anilistId, 20);
} finally {
globalThis.fetch = originalFetch;
db.close();
@@ -324,3 +356,187 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
sourcePath: '/tmp/cover-fetcher-unresolved.mkv',
sourceType: SOURCE_TYPE_LOCAL,
sourceUrl: null,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('graphql')) {
return Promise.resolve(
new Response(Buffer.from('01020304'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
}
const payload = JSON.parse(String(init?.body ?? '{}')) as {
variables?: { search?: string; id?: number };
};
if (typeof payload.variables?.id === 'number') {
// No sequel edges, so season 3 cannot be reached from the season 1 anchor.
return Promise.resolve(createJsonResponse({ data: { Media: { relations: { edges: [] } } } }));
}
return Promise.resolve(
createJsonResponse({
data: {
Page: {
media: [
{
id: 55,
episodes: 13,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
title: { romaji: 'Unresolved Show', english: 'Unresolved Show', native: null },
},
],
},
},
}),
);
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () =>
JSON.stringify({ title: 'Unresolved Show', season: 3, episode: 1, year: 2013 }),
},
);
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Unresolved Show');
const stored = getCoverArt(db, videoId);
assert.equal(fetched, false);
// Storing the season 1 artwork would leave a blob with no AniList id, which the
// `existing.coverBlob` early return serves forever - the season could then never
// re-resolve. A plain no-match keeps the existing retry window in play instead.
assert.equal(stored?.coverBlob, null);
assert.equal(stored?.coverUrl, null);
assert.equal(stored?.anilistId, null);
assert.equal(stored?.episodesTotal, null);
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
sourcePath: '/tmp/cover-fetcher-recovers.mkv',
sourceType: SOURCE_TYPE_LOCAL,
sourceUrl: null,
});
let sequelPublished = false;
const originalFetch = globalThis.fetch;
const originalNow = Date.now;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('graphql')) {
return Promise.resolve(
new Response(Buffer.from('01020304'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
}
const payload = JSON.parse(String(init?.body ?? '{}')) as {
variables?: { search?: string; id?: number };
};
if (typeof payload.variables?.id === 'number') {
return Promise.resolve(
createJsonResponse({
data: {
Media: {
relations: {
edges: sequelPublished
? [
{
relationType: 'SEQUEL',
node: {
id: 66,
type: 'ANIME',
episodes: 12,
format: 'TV',
seasonYear: 2015,
coverImage: { large: 'https://images.test/s2.jpg', medium: null },
title: { romaji: 'Recovering Show 2', english: null, native: null },
},
},
]
: [],
},
},
},
}),
);
}
return Promise.resolve(
createJsonResponse({
data: {
Page: {
media: [
{
id: 65,
episodes: 13,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
title: { romaji: 'Recovering Show', english: null, native: null },
},
],
},
},
}),
);
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () =>
JSON.stringify({ title: 'Recovering Show', season: 2, episode: 1, year: 2013 }),
},
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), false);
assert.equal(getCoverArt(db, videoId)?.anilistId, null);
// AniList publishes the sequel relation, and the no-match retry window elapses.
sequelPublished = true;
const base = originalNow();
Date.now = () => base + 10 * 60 * 1000;
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), true);
const recovered = getCoverArt(db, videoId);
assert.equal(recovered?.anilistId, 66);
assert.equal(recovered?.episodesTotal, 12);
} finally {
Date.now = originalNow;
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
+65 -191
View File
@@ -11,43 +11,15 @@ import {
runGuessit,
type GuessAnilistMediaInfoDeps,
} from './anilist-updater';
import {
resolveAnilistSeasonMedia,
type AnilistQueryExecutor,
type AnilistSeasonResolution,
} from './season-resolver';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
const SEARCH_QUERY = `
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
season
seasonYear
coverImage { large medium }
title { romaji english native }
}
}
}
`;
interface AnilistMedia {
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}
interface AnilistSearchResponse {
data?: {
Page?: {
media?: AnilistMedia[];
};
};
errors?: Array<{ message?: string }>;
}
export interface CoverArtFetcher {
fetchIfMissing(db: DatabaseSync, videoId: number, canonicalTitle: string): Promise<boolean>;
}
@@ -99,151 +71,45 @@ export function stripFilenameTags(raw: string): string {
return title.trim().replace(/\s{2,}/g, ' ');
}
function removeSeasonHint(title: string): string {
return title
.replace(/\bseason\s*\d+\b/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
class AnilistRateLimitedError extends Error {
constructor() {
super('Anilist rate limit reached');
this.name = 'AnilistRateLimitedError';
}
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function extractCandidateSeasonHints(text: string): Set<number> {
const normalized = normalizeTitle(text);
const matches = [
...normalized.matchAll(/\bseason\s*(\d{1,2})\b/gi),
...normalized.matchAll(/\bs(\d{1,2})(?:\b|\D)/gi),
];
const values = new Set<number>();
for (const match of matches) {
const value = Number.parseInt(match[1]!, 10);
if (Number.isInteger(value)) {
values.add(value);
}
}
return values;
}
function isSeasonMentioned(titles: string[], season: number | null): boolean {
if (!season) {
return false;
}
const hints = titles.flatMap((title) => [...extractCandidateSeasonHints(title)]);
return hints.includes(season);
}
function pickBestSearchResult(
title: string,
episode: number | null,
season: number | null,
media: AnilistMedia[],
): { id: number; title: string } | null {
const cleanedTitle = removeSeasonHint(title);
const targets = [title, cleanedTitle]
.map(normalizeTitle)
.map((value) => value.trim())
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
const filtered =
episode === null
? media
: media.filter((item) => {
const total = item.episodes;
return total === null || total >= episode;
});
const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) {
return null;
}
const scored = candidates.map((item) => {
const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
let score = 0;
for (const target of targets) {
if (candidateTitles.includes(target)) {
score += 120;
continue;
}
if (candidateTitles.some((itemTitle) => itemTitle.includes(target))) {
score += 30;
}
if (candidateTitles.some((itemTitle) => target.includes(itemTitle))) {
score += 10;
}
}
if (episode !== null && item.episodes === episode) {
score += 20;
}
if (season !== null && isSeasonMentioned(candidateTitles, season)) {
score += 15;
}
return { item, score };
});
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.item.id - a.item.id;
});
const selected = scored[0]!;
const selectedTitle =
selected.item.title?.english ??
selected.item.title?.romaji ??
selected.item.title?.native ??
title;
return { id: selected.item.id, title: selectedTitle };
}
function buildSearchCandidates(parsed: CoverArtCandidate): string[] {
const candidateTitles = [
...(parsed.source === 'guessit' && parsed.season !== null && parsed.season > 1
? [`${parsed.title} Season ${parsed.season}`]
: []),
parsed.title,
];
return candidateTitles
.map((title) => title.trim())
.filter((title, index, all) => title.length > 0 && all.indexOf(title) === index);
}
async function searchAnilist(
async function executeAnilistQuery<T>(
rateLimiter: AnilistRateLimiter,
title: string,
): Promise<{ media: AnilistMedia[]; rateLimited: boolean }> {
query: string,
variables: Record<string, unknown>,
): Promise<T> {
await rateLimiter.acquire();
const res = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ query: SEARCH_QUERY, variables: { search: title } }),
body: JSON.stringify({ query, variables }),
});
rateLimiter.recordResponse(res.headers);
if (res.status === 429) {
return { media: [], rateLimited: true };
throw new AnilistRateLimitedError();
}
if (!res.ok) {
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as AnilistSearchResponse;
const mediaList = json.data?.Page?.media;
if (!mediaList || mediaList.length === 0) {
return { media: [], rateLimited: false };
const json = (await res.json()) as { data?: T; errors?: Array<{ message?: string }> };
const firstError = json.errors?.find((entry) => Boolean(entry?.message));
if (firstError?.message) {
throw new Error(firstError.message);
}
return { media: mediaList, rateLimited: false };
if (!json.data) {
throw new Error('Anilist response missing data');
}
return json.data;
}
async function downloadImage(url: string): Promise<Buffer | null> {
@@ -376,49 +242,57 @@ export function createCoverArtFetcher(
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
const searchBase = parsedInfo?.title ?? cleaned;
const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned];
const searchTitles = searchBase === cleaned ? [searchBase] : ([searchBase, cleaned] as const);
const effectiveCandidates = searchCandidates.includes(cleaned)
? searchCandidates
: [...searchCandidates, cleaned];
let selected: AnilistMedia | null = null;
let rateLimited = false;
for (const candidate of effectiveCandidates) {
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', candidate, videoId);
const execute: AnilistQueryExecutor = (query, variables) =>
executeAnilistQuery(rateLimiter, query, variables);
let resolution: AnilistSeasonResolution | null = null;
try {
const result = await searchAnilist(rateLimiter, candidate);
rateLimited = result.rateLimited;
if (result.media.length === 0) {
continue;
}
const picked = pickBestSearchResult(
searchBase,
parsedInfo?.episode ?? null,
parsedInfo?.season ?? null,
result.media,
for (const searchTitle of searchTitles) {
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', searchTitle, videoId);
resolution = await resolveAnilistSeasonMedia(
{
title: searchTitle,
season: parsedInfo?.season ?? null,
episode: parsedInfo?.episode ?? null,
},
{ execute, logInfo: (message) => logger.info('%s', message) },
);
if (picked) {
const match = result.media.find((media) => media.id === picked.id);
if (match) {
selected = match;
break;
}
if (resolution) break;
}
} catch (err) {
logger.error('cover-art: Anilist search error for "%s": %s', candidate, err);
return false;
}
}
if (rateLimited) {
if (err instanceof AnilistRateLimitedError) {
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
return false;
}
logger.error('cover-art: Anilist search error for "%s": %s', searchBase, err);
return false;
}
if (resolution && !resolution.seasonResolved) {
// Only the season 1 entry was found. Storing its artwork would leave a cover with
// no AniList id, which the `existing.coverBlob` early return above serves forever,
// so the season could never re-resolve once AniList publishes the relation.
// Caching a plain no-match instead reuses the NO_MATCH_RETRY_MS retry window.
logger.warn(
'cover-art: could not find season %d of "%s" (only matched "%s"), caching no-match',
resolution.requestedSeason,
searchBase,
resolution.title,
);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
return false;
}
const selected = resolution?.media ?? null;
if (!selected) {
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
upsertCoverArt(db, videoId, {
@@ -0,0 +1,307 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ANILIST_SEASON_RELATIONS_QUERY,
ANILIST_SEASON_SEARCH_QUERY,
pickByAirOrder,
resolveAnilistSeasonMedia,
stripSeasonSuffix,
type AnilistSeasonMedia,
} from './season-resolver';
/** Real AniList payloads for "My Teen Romantic Comedy SNAFU", trimmed to used fields. */
const OREGAIRU_SEARCH: AnilistSeasonMedia[] = [
{
id: 14813,
episodes: 13,
format: 'TV',
seasonYear: 2013,
title: {
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru.',
english: 'My Teen Romantic Comedy SNAFU',
native: null,
},
},
{
id: 18753,
episodes: 1,
format: 'OVA',
seasonYear: 2013,
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU OVA', native: null },
},
{
id: 108489,
episodes: 12,
format: 'TV',
seasonYear: 2020,
title: {
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Kan',
english: 'My Teen Romantic Comedy SNAFU Climax!',
native: null,
},
},
{
id: 20698,
episodes: 13,
format: 'TV',
seasonYear: 2015,
title: {
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Zoku',
english: 'My Teen Romantic Comedy SNAFU TOO!',
native: null,
},
},
];
const OREGAIRU_RELATIONS: Record<
number,
Array<{ relationType: string; node: AnilistSeasonMedia }>
> = {
14813: [
{
relationType: 'SIDE_STORY',
node: OREGAIRU_SEARCH[1]!,
},
{
relationType: 'SEQUEL',
node: OREGAIRU_SEARCH[3]!,
},
],
20698: [
{
relationType: 'PREQUEL',
node: OREGAIRU_SEARCH[0]!,
},
{
relationType: 'SEQUEL',
node: OREGAIRU_SEARCH[2]!,
},
],
108489: [
{
relationType: 'PREQUEL',
node: OREGAIRU_SEARCH[3]!,
},
],
};
function createExecutor(
search: AnilistSeasonMedia[],
relations: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {},
) {
const searches: string[] = [];
const relationLookups: number[] = [];
const execute = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
if (query === ANILIST_SEASON_SEARCH_QUERY) {
searches.push(String(variables.search));
return { Page: { media: search } } as T;
}
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
const id = Number(variables.id);
relationLookups.push(id);
return {
Media: {
relations: {
edges: (relations[id] ?? []).map((edge) => ({
relationType: edge.relationType,
node: { ...edge.node, type: 'ANIME' },
})),
},
},
} as T;
}
throw new Error(`unexpected query: ${query}`);
};
return { execute, searches, relationLookups };
}
test('stripSeasonSuffix drops release-name season markers', () => {
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show 2nd Season'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show'), 'Some Show');
});
test('resolves season 3 by walking sequel relations', async () => {
const { execute, searches, relationLookups } = createExecutor(
OREGAIRU_SEARCH,
OREGAIRU_RELATIONS,
);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 108489);
assert.equal(result?.title, 'My Teen Romantic Comedy SNAFU Climax!');
assert.equal(result?.episodes, 12);
assert.equal(result?.seasonResolved, true);
assert.equal(result?.via, 'sequel-chain');
// Searches the bare title only; "Season 3" returns nothing on AniList.
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
assert.deepEqual(relationLookups, [14813, 20698]);
});
test('season 1 resolves to the anchor without relation lookups', async () => {
const { execute, relationLookups } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 1, episode: 5 },
{ execute },
);
assert.equal(result?.id, 14813);
assert.equal(result?.seasonResolved, true);
assert.equal(result?.via, 'anchor');
assert.deepEqual(relationLookups, []);
});
test('strips a season marker already present in the parsed title', async () => {
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU Season 2', season: 2, episode: 3 },
{ execute },
);
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
assert.equal(result?.id, 20698);
assert.equal(result?.seasonResolved, true);
});
test('falls back to air order when the sequel chain is broken', async () => {
const { execute } = createExecutor(OREGAIRU_SEARCH, {});
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 108489);
assert.equal(result?.via, 'air-order');
assert.equal(result?.seasonResolved, true);
});
test('reports seasonResolved false rather than silently using season 1', async () => {
const { execute } = createExecutor([OREGAIRU_SEARCH[0]!], {});
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 14813);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
assert.equal(result?.requestedSeason, 3);
});
test('does not filter the anchor by an episode count the season 1 entry cannot have', async () => {
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
// Season 2 episode 13 exceeds nothing, but season 3 episode 13 exceeds Climax!'s 12.
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 13 },
{ execute },
);
assert.equal(result?.id, 20698);
assert.equal(result?.seasonResolved, true);
});
test('prefers the TV sequel when a franchise branches into other formats', async () => {
const branching: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {
1: [
{
relationType: 'SEQUEL',
node: {
id: 3,
episodes: 6,
format: 'ONA',
seasonYear: 2019,
title: { english: 'Spinoff' },
},
},
{
relationType: 'SEQUEL',
node: { id: 2, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show 2' } },
},
],
};
const { execute } = createExecutor(
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2018, title: { english: 'Show' } }],
branching,
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
assert.equal(result?.id, 2);
assert.equal(result?.via, 'sequel-chain');
});
test('air-order fallback refuses when the anchor is not the earliest entry', () => {
const anchor: AnilistSeasonMedia = {
id: 2,
format: 'TV',
seasonYear: 2020,
title: { english: 'Show Later' },
};
const picked = pickByAirOrder(anchor, 2, [
anchor,
{ id: 1, format: 'TV', seasonYear: 2015, title: { english: 'Show Earlier' } },
]);
assert.equal(picked, null);
});
test('returns null when the search yields nothing', async () => {
const { execute } = createExecutor([]);
const result = await resolveAnilistSeasonMedia({ title: 'Nothing', season: 2 }, { execute });
assert.equal(result, null);
});
test('refuses a season beyond the sequel-hop cap instead of returning a partial walk', async () => {
// A 30-entry sequel chain: hopping the capped number of times would land on the wrong
// season and report it resolved, so the walk must decline outright.
const chain: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {};
for (let id = 1; id < 30; id += 1) {
chain[id] = [
{
relationType: 'SEQUEL',
node: {
id: id + 1,
episodes: 12,
format: 'TV',
seasonYear: 2000 + id,
title: { english: `Show ${id + 1}` },
},
},
];
}
const { execute, relationLookups } = createExecutor(
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2000, title: { english: 'Show' } }],
chain,
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 25 }, { execute });
assert.equal(result?.id, 1);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
// Declines before spending any relation requests.
assert.deepEqual(relationLookups, []);
});
test('air-order fallback declines when a franchise entry has no air year', async () => {
// The middle season has no year, so ordering it would shift every later season by one.
const { execute } = createExecutor(
[
{ id: 1, episodes: 12, format: 'TV', seasonYear: 2013, title: { english: 'Show' } },
{ id: 2, episodes: 12, format: 'TV', title: { english: 'Show Zoku' } },
{ id: 3, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show Kan' } },
],
{},
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
assert.equal(result?.id, 1);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
});
@@ -0,0 +1,407 @@
/**
* AniList has no concept of "season N": sequels are separate media with their own
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
* nothing and callers silently fall back to the season 1 entry.
*
* This module resolves a parsed (title, season) pair to the right media by locating a
* season 1 anchor and then walking AniList SEQUEL relations, with an air-order fallback
* when the relation chain is incomplete. When the seasonal entry cannot be located it
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
*/
export interface AnilistSeasonMediaTitle {
romaji?: string | null;
english?: string | null;
native?: string | null;
}
export interface AnilistSeasonMedia {
id: number;
episodes?: number | null;
format?: string | null;
seasonYear?: number | null;
startDate?: { year?: number | null } | null;
synonyms?: Array<string | null> | null;
coverImage?: { large?: string | null; medium?: string | null } | null;
title?: AnilistSeasonMediaTitle | null;
}
export type AnilistQueryExecutor = <T>(
query: string,
variables: Record<string, unknown>,
) => Promise<T>;
export type AnilistSeasonResolutionVia = 'anchor' | 'sequel-chain' | 'air-order';
export interface AnilistSeasonResolution {
id: number;
title: string;
episodes: number | null;
media: AnilistSeasonMedia;
/** False when a season >= 2 was requested but no seasonal entry could be located. */
seasonResolved: boolean;
requestedSeason: number | null;
via: AnilistSeasonResolutionVia;
}
export interface ResolveAnilistSeasonMediaInput {
title: string;
season?: number | null;
episode?: number | null;
}
export interface ResolveAnilistSeasonMediaDeps {
execute: AnilistQueryExecutor;
logInfo?: (message: string) => void;
}
const MEDIA_FIELDS = `
id
episodes
format
seasonYear
startDate { year }
synonyms
coverImage { large medium }
title { romaji english native }
`;
export const ANILIST_SEASON_SEARCH_QUERY = `
query ($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
${MEDIA_FIELDS}
}
}
}
`;
export const ANILIST_SEASON_RELATIONS_QUERY = `
query ($id: Int!) {
Media(id: $id, type: ANIME) {
id
relations {
edges {
relationType
node {
type
${MEDIA_FIELDS}
}
}
}
}
}
`;
interface AnilistSeasonSearchResponse {
Page?: {
media?: AnilistSeasonMedia[] | null;
} | null;
}
interface AnilistSeasonRelationsResponse {
Media?: {
relations?: {
edges?: Array<{
relationType?: string | null;
node?: (AnilistSeasonMedia & { type?: string | null }) | null;
} | null> | null;
} | null;
} | null;
}
/** Formats that can carry a numbered TV season, best first. */
const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
const MAX_SEQUEL_HOPS = 12;
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
/**
* Drops season markers a release name carries but AniList titles never do,
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
*/
export function stripSeasonSuffix(title: string): string {
return title
.replace(/\bseason\s*\d{1,2}\b/gi, ' ')
.replace(/\b\d{1,2}(?:st|nd|rd|th)\s+season\b/gi, ' ')
.replace(/\bs\d{1,2}\b/gi, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
}
function mediaTitles(media: AnilistSeasonMedia): string[] {
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => normalizeTitle(value));
}
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
return (
media.title?.english?.trim() ||
media.title?.romaji?.trim() ||
media.title?.native?.trim() ||
fallback.trim()
);
}
function episodeCount(media: AnilistSeasonMedia): number | null {
return typeof media.episodes === 'number' && media.episodes > 0 ? media.episodes : null;
}
function airYear(media: AnilistSeasonMedia): number | null {
if (typeof media.seasonYear === 'number' && media.seasonYear > 0) {
return media.seasonYear;
}
const startYear = media.startDate?.year;
return typeof startYear === 'number' && startYear > 0 ? startYear : null;
}
function isSeasonalFormat(media: AnilistSeasonMedia): boolean {
const format = (media.format || '').toUpperCase();
return SEASONAL_FORMAT_PRIORITY.includes(format);
}
function formatRank(media: AnilistSeasonMedia): number {
const index = SEASONAL_FORMAT_PRIORITY.indexOf((media.format || '').toUpperCase());
return index < 0 ? SEASONAL_FORMAT_PRIORITY.length : index;
}
function toResolution(
media: AnilistSeasonMedia,
fallbackTitle: string,
season: number | null,
via: AnilistSeasonResolutionVia,
seasonResolved: boolean,
): AnilistSeasonResolution {
return {
id: media.id,
title: displayTitle(media, fallbackTitle),
episodes: episodeCount(media),
media,
seasonResolved,
requestedSeason: season,
via,
};
}
/**
* Picks the franchise anchor (season 1 entry) for a search result set. The anchor is
* matched on title alone - season is handled by walking relations from here.
*/
export function pickAnchorMedia(
title: string,
media: AnilistSeasonMedia[],
options: { episode?: number | null } = {},
): AnilistSeasonMedia | null {
if (media.length === 0) return null;
const episode = options.episode;
const episodeFiltered =
typeof episode === 'number' && episode > 0
? media.filter((entry) => {
const total = episodeCount(entry);
return total === null || total >= episode;
})
: media;
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter(
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
);
const scored = pool.map((entry, index) => {
const candidateTitles = mediaTitles(entry);
let score = 0;
for (const target of targets) {
if (candidateTitles.includes(target)) {
score += 120;
continue;
}
if (candidateTitles.some((candidate) => candidate.startsWith(target))) {
score += 40;
} else if (candidateTitles.some((candidate) => candidate.includes(target))) {
score += 25;
}
if (candidateTitles.some((candidate) => target.includes(candidate))) {
score += 10;
}
}
if (isSeasonalFormat(entry)) {
score += 30;
}
return { entry, score, index };
});
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.index !== b.index) return a.index - b.index;
return a.entry.id - b.entry.id;
});
return scored[0]?.entry ?? null;
}
async function walkSequelChain(
anchor: AnilistSeasonMedia,
season: number,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonMedia | null> {
// Walking fewer hops than requested would land on the wrong season and report it as
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
const hops = season - 1;
if (hops > MAX_SEQUEL_HOPS) {
return null;
}
const visited = new Set<number>([anchor.id]);
let current = anchor;
for (let hop = 0; hop < hops; hop += 1) {
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
const response = await deps.execute<AnilistSeasonRelationsResponse>(
ANILIST_SEASON_RELATIONS_QUERY,
{ id: current.id },
);
const sequels = (response.Media?.relations?.edges ?? [])
.map((edge) => edge ?? null)
.filter(
(
edge,
): edge is {
relationType?: string | null;
node: AnilistSeasonMedia & { type?: string | null };
} =>
Boolean(edge?.node) &&
(edge?.relationType || '').toUpperCase() === 'SEQUEL' &&
(edge?.node?.type || 'ANIME').toUpperCase() === 'ANIME',
)
.map((edge) => edge.node)
.filter((node) => !visited.has(node.id));
if (sequels.length === 0) {
return null;
}
// A franchise can branch (a TV sequel plus an ONA spinoff); prefer the TV line.
sequels.sort((a, b) => {
const rankDelta = formatRank(a) - formatRank(b);
if (rankDelta !== 0) return rankDelta;
const yearDelta =
(airYear(a) ?? Number.MAX_SAFE_INTEGER) - (airYear(b) ?? Number.MAX_SAFE_INTEGER);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
});
current = sequels[0]!;
visited.add(current.id);
}
return current.id === anchor.id ? null : current;
}
/**
* Fallback for franchises whose SEQUEL edges are missing or route through a format we
* skipped: order the franchise's seasonal entries by air date and index by season.
*/
export function pickByAirOrder(
anchor: AnilistSeasonMedia,
season: number,
media: AnilistSeasonMedia[],
): AnilistSeasonMedia | null {
const anchorTitles = mediaTitles(anchor).map((value) => stripSeasonSuffix(value));
const anchorBase = anchorTitles.sort((a, b) => a.length - b.length)[0];
if (!anchorBase) return null;
const franchise = media.filter((entry) => {
if (!isSeasonalFormat(entry)) return false;
if (entry.id === anchor.id) return true;
return mediaTitles(entry).some((candidate) =>
stripSeasonSuffix(candidate).includes(anchorBase),
);
});
if (franchise.length < season) return null;
// Ordering by air date is only meaningful when every entry has one: an unknown year
// would sort last and shift the season index while still reporting a resolved match.
if (franchise.some((entry) => airYear(entry) === null)) return null;
const ordered = [...franchise].sort((a, b) => {
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
});
// The anchor has to be the first entry, otherwise this ordering is not a season list.
if (ordered[0]?.id !== anchor.id) return null;
return ordered[season - 1] ?? null;
}
export async function resolveAnilistSeasonMedia(
input: ResolveAnilistSeasonMediaInput,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonResolution | null> {
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
if (!searchTitle) return null;
const season =
typeof input.season === 'number' && Number.isInteger(input.season) && input.season > 0
? input.season
: null;
const response = await deps.execute<AnilistSeasonSearchResponse>(ANILIST_SEASON_SEARCH_QUERY, {
search: searchTitle,
});
const media = (response.Page?.media ?? []).filter(
(entry): entry is AnilistSeasonMedia => Boolean(entry) && typeof entry.id === 'number',
);
if (media.length === 0) return null;
// Season 1 (or unknown) resolves against the episode count; later seasons must not,
// because the anchor is season 1 and may be shorter than the requested episode.
const anchor = pickAnchorMedia(searchTitle, media, {
episode: season === null || season <= 1 ? input.episode : null,
});
if (!anchor) return null;
if (season === null || season <= 1) {
return toResolution(anchor, searchTitle, season, 'anchor', true);
}
let chainError: unknown = null;
let viaChain: AnilistSeasonMedia | null = null;
try {
viaChain = await walkSequelChain(anchor, season, deps);
} catch (error) {
chainError = error;
}
if (viaChain) {
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
);
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
}
const viaAirOrder = pickByAirOrder(anchor, season, media);
if (viaAirOrder) {
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
);
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
}
// The chain failed for transport reasons rather than because the season is absent;
// surface that so callers retry instead of reporting an unresolvable season.
if (chainError) {
throw chainError;
}
deps.logInfo?.(
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
);
return toResolution(anchor, searchTitle, season, 'anchor', false);
}
@@ -1466,6 +1466,190 @@ test('deleteVideo ignores the currently active video and keeps new writes flusha
}
});
/**
* Attach the derived rows a real session would produce to every recorded line
* of `animeId`: word/kanji entries and their occurrences, monthly rollups, and
* cover art backed by a shared blob.
*/
function seedDerivedAnimeData(db: DatabaseSync, animeId: number): void {
const lines = db
.prepare(
'SELECT line_id AS lineId, CREATED_DATE AS seenMs FROM imm_subtitle_lines WHERE anime_id = ?',
)
.all(animeId) as Array<{ lineId: number; seenMs: number }>;
assert.ok(lines.length > 0, 'expected recorded subtitle lines to decorate');
db.prepare(
`INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
VALUES (9001, '天気', '天気', 'てんき', 'noun', '名詞', 0, 0, 0)`,
).run();
db.prepare(
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (9101, '気', 0, 0, 0)`,
).run();
const insertWordOccurrence = db.prepare(
'INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (?, 9001, 1, ?)',
);
const insertKanjiOccurrence = db.prepare(
'INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms) VALUES (?, 9101, 1, ?)',
);
for (const line of lines) {
insertWordOccurrence.run(line.lineId, line.seenMs);
insertKanjiOccurrence.run(line.lineId, line.seenMs);
}
db.prepare(
`UPDATE imm_words SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = 9001`,
).run(lines.length, 0, 0);
const videoIds = (
db
.prepare('SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ videoId: number }>
).map((row) => row.videoId);
const insertMonthlyRollup = db.prepare(
`INSERT INTO imm_monthly_rollups(rollup_month, video_id, total_sessions, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (202401, ?, 1, '0', '0')`,
);
const insertArt = db.prepare(
`INSERT INTO imm_media_art(video_id, anilist_id, cover_url, cover_blob, cover_blob_hash, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, 4242, 'https://example.test/cover.jpg', NULL, 'deadbeef', '0', '0', '0')`,
);
db.prepare(
`INSERT INTO imm_cover_art_blobs(blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('deadbeef', X'FFD8FFD9', '0', '0')`,
).run();
for (const videoId of videoIds) {
insertMonthlyRollup.run(videoId);
insertArt.run(videoId);
}
}
test('deleteAnime removes every episode, session and library row for the title', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
for (const episode of ['S02E05', 'S02E06']) {
tracker = new Ctor({ dbPath });
tracker.handleMediaChange(`/tmp/Little Witch Academia ${episode}.mkv`, `Episode ${episode}`);
await waitForPendingAnimeMetadata(tracker);
tracker.recordSubtitleLine('今日は晴れです', 0, 1.2);
tracker.recordCardsMined(1);
tracker.destroy();
tracker = null;
}
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
const animeId = (
privateApi.db.prepare('SELECT anime_id FROM imm_anime LIMIT 1').get() as {
anime_id: number;
} | null
)?.anime_id;
assert.ok(animeId);
// The tokenizer does not run in this harness, so attach vocabulary, kanji,
// rollups and cover art to the recorded lines by hand. Without them the
// "everything is gone" assertions below would pass against empty tables.
seedDerivedAnimeData(privateApi.db, animeId);
const countOf = (sql: string): number =>
(privateApi.db.prepare(sql).get() as { total: number }).total;
for (const table of [
'imm_words',
'imm_kanji',
'imm_word_line_occurrences',
'imm_kanji_line_occurrences',
'imm_daily_rollups',
'imm_monthly_rollups',
'imm_media_art',
'imm_cover_art_blobs',
]) {
assert.ok(
countOf(`SELECT COUNT(*) AS total FROM ${table}`) > 0,
`precondition: ${table} should hold rows before the delete`,
);
}
const libraryBefore = await tracker.getAnimeLibrary();
assert.equal(libraryBefore.length, 1);
assert.equal(libraryBefore[0]?.episodeCount, 2);
await tracker.deleteAnime(animeId);
const libraryAfter = await tracker.getAnimeLibrary();
assert.equal(libraryAfter.length, 0);
for (const table of [
'imm_anime',
'imm_lifetime_anime',
'imm_videos',
'imm_sessions',
'imm_subtitle_lines',
'imm_daily_rollups',
'imm_monthly_rollups',
'imm_lifetime_media',
'imm_words',
'imm_kanji',
'imm_word_line_occurrences',
'imm_kanji_line_occurrences',
'imm_media_art',
'imm_cover_art_blobs',
]) {
assert.equal(
countOf(`SELECT COUNT(*) AS total FROM ${table}`),
0,
`${table} should be empty after deleting the only title`,
);
}
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('deleteAnime ignores the title of the currently active session', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5');
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as {
db: DatabaseSync;
sessionState: { sessionId: number; videoId: number } | null;
};
const videoId = privateApi.sessionState?.videoId;
assert.ok(videoId);
const animeId = (
privateApi.db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
anime_id: number | null;
} | null
)?.anime_id;
assert.ok(animeId);
await tracker.deleteAnime(animeId);
const animeCountRow = privateApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_anime WHERE anime_id = ?')
.get(animeId) as { total: number };
const videoCountRow = privateApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_videos WHERE video_id = ?')
.get(videoId) as { total: number };
assert.equal(animeCountRow.total, 1);
assert.equal(videoCountRow.total, 1);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('handleMediaChange links parsed anime metadata on the active video row', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -83,6 +83,7 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
deleteAnime as deleteAnimeQuery,
deleteSession as deleteSessionQuery,
deleteSessions as deleteSessionsQuery,
deleteVideo as deleteVideoQuery,
@@ -733,6 +734,28 @@ export class ImmersionTrackerService {
deleteVideoQuery(this.db, videoId);
}
async deleteAnime(animeId: number): Promise<void> {
// The active video's anime link is assigned asynchronously after the title
// is parsed, so a guard reading imm_videos too early sees a null and lets
// the delete through — then the late update recreates the anime row.
const pendingVideoId = this.sessionState?.videoId;
if (pendingVideoId !== undefined) {
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
}
const activeVideoId = this.sessionState?.videoId;
if (activeVideoId !== undefined) {
const activeAnime = this.db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(activeVideoId) as { anime_id: number | null } | null;
if (activeAnime?.anime_id === animeId) {
this.logger.warn(`Ignoring delete request for active immersion anime ${animeId}`);
return;
}
}
deleteAnimeQuery(this.db, animeId);
}
async reassignAnimeAnilist(
animeId: number,
info: {
@@ -0,0 +1,331 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { ensureSchema } from '../storage.js';
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
const DAY_MS = 86_400_000;
const BASE_MS = 1_700_000_000_000;
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-removal-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
/**
* Seed two episodes of one anime, each with one ended session.
*
* `lines` places a word occurrence on a specific day so tests can control which
* session holds a word's first/last occurrence.
*/
function seed(
db: DatabaseSync,
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
options: { legacyRows?: boolean } = {},
): void {
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'v1', 1, 'Ep 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS}),
(2, 'v2', 1, 'Ep 2', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 's1', 1, '${BASE_MS}', '${BASE_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS}),
(2, 's2', 2, '${BASE_MS + DAY_MS}', '${BASE_MS + DAY_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS});
`);
const insertLine = db.prepare(
`INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, 1, ?, ?, ?, ?)`,
);
const insertWord = db.prepare(
`INSERT OR IGNORE INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
VALUES (?, ?, ?, '', 'noun', '名詞', 0, 0, 0)`,
);
// `legacyRows` reproduces databases written before the seen_ms column existed,
// where the timestamp has to be read back off the subtitle line.
const insertOccurrence = options.legacyRows
? db.prepare(
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count) VALUES (?, ?, ?)`,
)
: db.prepare(
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)`,
);
let lineId = 0;
for (const line of lines) {
lineId += 1;
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
insertWord.run(line.wordId, `${line.wordId}`, `${line.wordId}`);
if (options.legacyRows) {
insertOccurrence.run(lineId, line.wordId, line.count ?? 1);
} else {
insertOccurrence.run(lineId, line.wordId, line.count ?? 1, seenMs);
}
}
// Match what the tracker maintains: aggregates derived from the occurrences.
db.exec(`
UPDATE imm_words SET
frequency = (
SELECT COALESCE(SUM(o.occurrence_count), 0)
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
),
first_seen = (
SELECT MIN(sl.CREATED_DATE) / 1000
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id
),
last_seen = (
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id
)
`);
}
function createDb(
lines: Parameters<typeof seed>[1],
options: Parameters<typeof seed>[2] = {},
): { db: DatabaseSync; dbPath: string } {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
seed(db, lines, options);
return { db, dbPath };
}
function readWord(
db: DatabaseSync,
wordId: number,
): { frequency: number; firstSeen: number; lastSeen: number } | null {
return (
(db
.prepare(
'SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM imm_words WHERE id = ?',
)
.get(wordId) as { frequency: number; firstSeen: number; lastSeen: number } | null) ?? null
);
}
test('deleting a session subtracts only the occurrences it removed', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 10, dayOffset: 0, count: 3 },
{ session: 2, wordId: 10, dayOffset: 1, count: 4 },
]);
try {
assert.equal(readWord(db, 10)?.frequency, 7);
deleteSession(db, 1);
assert.equal(readWord(db, 10)?.frequency, 4, 'only session 1 occurrences are subtracted');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting the session that held a word first drops the word entirely', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 11, dayOffset: 0 },
{ session: 2, wordId: 12, dayOffset: 1 },
]);
try {
deleteSession(db, 1);
assert.equal(readWord(db, 11), null, 'word seen only in the deleted session is removed');
assert.ok(readWord(db, 12), 'word seen elsewhere survives');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting the earliest session moves first_seen forward to the surviving line', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 13, dayOffset: 0 },
{ session: 2, wordId: 13, dayOffset: 5 },
]);
try {
assert.equal(readWord(db, 13)?.firstSeen, Math.floor(BASE_MS / 1000));
deleteSession(db, 1);
const word = readWord(db, 13);
assert.equal(word?.frequency, 1);
assert.equal(
word?.firstSeen,
Math.floor((BASE_MS + 5 * DAY_MS) / 1000),
'first_seen advances to the remaining occurrence',
);
assert.equal(word?.lastSeen, Math.floor((BASE_MS + 5 * DAY_MS) / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting the latest session moves last_seen back to the surviving line', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 14, dayOffset: 0 },
{ session: 2, wordId: 14, dayOffset: 5 },
]);
try {
deleteSession(db, 2);
const word = readWord(db, 14);
assert.equal(word?.frequency, 1);
assert.equal(word?.lastSeen, Math.floor(BASE_MS / 1000), 'last_seen falls back to session 1');
assert.equal(word?.firstSeen, Math.floor(BASE_MS / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
// Session 2 carries the middle occurrence; sessions bracket it in time.
const { db, dbPath } = createDb([
{ session: 1, wordId: 15, dayOffset: 0 },
{ session: 2, wordId: 15, dayOffset: 3 },
{ session: 1, wordId: 15, dayOffset: 9 },
]);
try {
deleteSessions(db, [2]);
const word = readWord(db, 15);
assert.equal(word?.frequency, 2);
assert.equal(word?.firstSeen, Math.floor(BASE_MS / 1000));
assert.equal(word?.lastSeen, Math.floor((BASE_MS + 9 * DAY_MS) / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a stored frequency that has drifted low is repaired instead of dropping a live word', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 16, dayOffset: 0, count: 5 },
{ session: 2, wordId: 16, dayOffset: 4, count: 5 },
]);
try {
// Simulate drift: the stored total is lower than the occurrences justify, so
// naive subtraction would take the word to zero while rows still reference it.
db.prepare('UPDATE imm_words SET frequency = 5 WHERE id = ?').run(16);
deleteSession(db, 1);
const word = readWord(db, 16);
assert.ok(word, 'word with surviving occurrences is not deleted');
assert.equal(word?.frequency, 5, 'frequency is recomputed from the surviving occurrences');
assert.equal(word?.firstSeen, Math.floor((BASE_MS + 4 * DAY_MS) / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting a video subtracts every occurrence carried by its lines', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 17, dayOffset: 0, count: 2 },
{ session: 1, wordId: 17, dayOffset: 1, count: 3 },
{ session: 2, wordId: 17, dayOffset: 2, count: 4 },
{ session: 2, wordId: 18, dayOffset: 2, count: 1 },
]);
try {
deleteVideo(db, 1);
assert.equal(readWord(db, 17)?.frequency, 4, 'both lines from video 1 are subtracted');
assert.equal(readWord(db, 18)?.frequency, 1, 'untouched video keeps its counts');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('occurrence rows written before the seen_ms column still resolve their dates', () => {
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 20, dayOffset: 0 },
{ session: 2, wordId: 20, dayOffset: 6 },
],
{ legacyRows: true },
);
try {
assert.equal(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_word_line_occurrences WHERE seen_ms IS NULL')
.get() as { total: number }
).total,
2,
'precondition: rows carry no denormalised timestamp',
);
deleteSession(db, 1);
const word = readWord(db, 20);
assert.equal(word?.frequency, 1);
assert.equal(
word?.firstSeen,
Math.floor((BASE_MS + 6 * DAY_MS) / 1000),
'falls back to the subtitle line timestamp',
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('upgrading an older database backfills seen_ms from the subtitle lines', () => {
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 21, dayOffset: 0 },
{ session: 2, wordId: 21, dayOffset: 2 },
],
{ legacyRows: true },
);
try {
// Re-run ensureSchema the way a pre-19 database would be opened.
db.exec('DELETE FROM imm_schema_version');
db.exec(`INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (18, '0')`);
ensureSchema(db);
const rows = db
.prepare('SELECT line_id AS lineId, seen_ms AS seenMs FROM imm_word_line_occurrences')
.all() as Array<{ lineId: number; seenMs: number | null }>;
assert.equal(rows.length, 2);
for (const row of rows) {
assert.ok(row.seenMs, `line ${row.lineId} should have a backfilled timestamp`);
}
assert.deepEqual(
rows.map((row) => row.seenMs).sort((a, b) => Number(a) - Number(b)),
[BASE_MS, BASE_MS + 2 * DAY_MS],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -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);
@@ -4982,3 +4993,145 @@ test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats counts the distinct anime each word appeared in', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const startedAtMs = 1_700_000_000_000;
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (11, 'vocab-a', 'Vocab A', ${startedAtMs}, ${startedAtMs}),
(22, 'vocab-b', 'Vocab B', ${startedAtMs}, ${startedAtMs});
`);
const videoIds = [1, 2].map((n) =>
getOrCreateVideoRecord(db, `local:/tmp/vocab-anime-${n}.mkv`, {
canonicalTitle: `Vocab Anime ${n}`,
sourcePath: `/tmp/vocab-anime-${n}.mkv`,
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
}),
);
const sessionIds = videoIds.map(
(videoId) => startSessionRecord(db, videoId, startedAtMs).sessionId,
);
// 猫 spans both titles, twice in the first, so the count has to deduplicate
// by anime rather than just tallying occurrence rows. 犬 stays in one title.
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[0]!,
videoId: videoIds[0]!,
animeId: 11,
lineIndex: 1,
occurrenceCount: 5,
startedAtMs,
word: '猫',
reading: 'ねこ',
});
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[0]!,
videoId: videoIds[0]!,
animeId: 11,
lineIndex: 2,
occurrenceCount: 1,
startedAtMs,
word: '猫',
reading: 'ねこ',
});
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[1]!,
videoId: videoIds[1]!,
animeId: 22,
lineIndex: 3,
occurrenceCount: 4,
startedAtMs,
word: '猫',
reading: 'ねこ',
});
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[0]!,
videoId: videoIds[0]!,
animeId: 11,
lineIndex: 4,
occurrenceCount: 2,
startedAtMs,
word: '犬',
reading: 'いぬ',
});
const rows = getVocabularyStats(db, 10);
const cat = rows.find((row) => row.headword === '猫');
const dog = rows.find((row) => row.headword === '犬');
assert.equal(cat?.animeCount, 2, '猫 was seen in two anime across three lines');
assert.equal(dog?.animeCount, 1, '犬 was seen in one');
assert.equal(cat?.frequency, 10, 'frequency still aggregates across both titles');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats still applies part-of-speech exclusions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const startedAtMs = 1_700_000_000_000;
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (11, 'vocab-a', 'Vocab A', ${startedAtMs}, ${startedAtMs});
`);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/vocab-exclude.mkv', {
canonicalTitle: 'Vocab Exclude',
sourcePath: '/tmp/vocab-exclude.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
insertFilteredWordOccurrence(db, {
sessionId,
videoId,
animeId: 11,
lineIndex: 1,
occurrenceCount: 9,
startedAtMs,
word: '走る',
reading: 'はしる',
partOfSpeech: 'verb',
});
insertFilteredWordOccurrence(db, {
sessionId,
videoId,
animeId: 11,
lineIndex: 2,
occurrenceCount: 3,
startedAtMs,
word: '猫',
reading: 'ねこ',
partOfSpeech: 'noun',
});
const all = getVocabularyStats(db, 10);
assert.deepEqual(
all.map((row) => row.headword).sort(),
['猫', '走る'],
'precondition: both parts of speech are present',
);
const nounsOnly = getVocabularyStats(db, 10, ['verb']);
assert.deepEqual(
nounsOnly.map((row) => row.headword),
['猫'],
'excluded part of speech is filtered out even though it ranks higher',
);
assert.equal(nounsOnly[0]?.animeCount, 1, 'surviving rows keep their anime count');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -115,18 +115,29 @@ export function getVocabularyStats(
const whereClause = hasExclude
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
: '';
// The page is selected before the join so `animeCount` is only computed for the
// rows being returned. Aggregating first made every request walk each word's
// entire occurrence history — seconds of blocked event loop on a large library,
// because only the ordering, not the aggregate, decides which rows survive.
const stmt = db.prepare(`
SELECT w.id AS wordId, w.headword, w.word, w.reading,
w.part_of_speech AS partOfSpeech, w.pos1, w.pos2, w.pos3,
w.frequency, w.frequency_rank AS frequencyRank,
w.first_seen AS firstSeen, w.last_seen AS lastSeen,
WITH page AS (
SELECT id, headword, word, reading, part_of_speech, pos1, pos2, pos3,
frequency, frequency_rank, first_seen, last_seen
FROM imm_words
${whereClause}
ORDER BY frequency DESC, id
LIMIT ? OFFSET ?
)
SELECT p.id AS wordId, p.headword, p.word, p.reading,
p.part_of_speech AS partOfSpeech, p.pos1, p.pos2, p.pos3,
p.frequency, p.frequency_rank AS frequencyRank,
p.first_seen AS firstSeen, p.last_seen AS lastSeen,
COUNT(DISTINCT sl.anime_id) AS animeCount
FROM imm_words w
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
FROM page p
LEFT JOIN imm_word_line_occurrences o ON o.word_id = p.id
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id AND sl.anime_id IS NOT NULL
${whereClause ? whereClause.replace('part_of_speech', 'w.part_of_speech') : ''}
GROUP BY w.id
ORDER BY w.frequency DESC LIMIT ? OFFSET ?
GROUP BY p.id
ORDER BY p.frequency DESC, p.id
`);
const visibleRows: VocabularyStatsRow[] = [];
let offset = 0;
@@ -9,14 +9,12 @@ import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
import {
applyLexicalRemovals,
cleanupUnusedCoverArtBlobHash,
deleteSessionsByIds,
findSharedCoverBlobHash,
getAffectedKanjiIdsForSessions,
getAffectedKanjiIdsForVideo,
getAffectedWordIdsForSessions,
getAffectedWordIdsForVideo,
refreshLexicalAggregates,
planLexicalRemovalsForSessions,
planLexicalRemovalsForVideos,
toDbMs,
toDbTimestamp,
} from './query-shared';
@@ -232,12 +230,13 @@ export async function cleanupVocabularyStats(
WHERE id = ?`,
);
const moveOccurrencesStmt = db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
SELECT line_id, ?, occurrence_count
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms)
SELECT line_id, ?, occurrence_count, seen_ms
FROM imm_word_line_occurrences
WHERE word_id = ?
ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`,
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(imm_word_line_occurrences.seen_ms, excluded.seen_ms)`,
);
const deleteOccurrencesStmt = db.prepare(
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
@@ -484,14 +483,16 @@ export function isVideoWatched(db: DatabaseSync, videoId: number): boolean {
export function deleteSession(db: DatabaseSync, sessionId: number): void {
const sessionIds = [sessionId];
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
// Measured inside the write lock: the plan records what the delete removes,
// and applying a plan taken against a different snapshot would subtract the
// wrong totals from imm_words/imm_kanji.
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
@@ -503,14 +504,13 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
if (sessionIds.length === 0) return;
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
@@ -520,7 +520,69 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
}
}
/**
* Delete an entire library entry: every episode of the anime, all of their
* sessions and derived stats, and the anime row itself.
*
* Mirrors {@link deleteVideo} per episode, but batches the lexical refresh and
* lifetime rebuild into a single transaction so a multi-episode title doesn't
* pay for one full rebuild per episode.
*/
export function deleteAnime(db: DatabaseSync, animeId: number): void {
db.exec('BEGIN IMMEDIATE');
try {
const videoIds = (
db.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{
video_id: number;
}>
).map((row) => row.video_id);
const lexicalRemovals = planLexicalRemovalsForVideos(db, videoIds);
const coverBlobHashes: string[] = [];
const sessionIds: number[] = [];
for (const videoId of videoIds) {
const artRow = db
.prepare('SELECT cover_blob_hash AS coverBlobHash FROM imm_media_art WHERE video_id = ?')
.get(videoId) as { coverBlobHash: string | null } | undefined;
if (artRow?.coverBlobHash) {
coverBlobHashes.push(artRow.coverBlobHash);
}
const sessions = db
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
.all(videoId) as Array<{ session_id: number }>;
sessionIds.push(...sessions.map((session) => session.session_id));
}
deleteSessionsByIds(db, sessionIds);
const deleteLinesStmt = db.prepare('DELETE FROM imm_subtitle_lines WHERE video_id = ?');
const deleteDailyStmt = db.prepare('DELETE FROM imm_daily_rollups WHERE video_id = ?');
const deleteMonthlyStmt = db.prepare('DELETE FROM imm_monthly_rollups WHERE video_id = ?');
const deleteArtStmt = db.prepare('DELETE FROM imm_media_art WHERE video_id = ?');
const deleteVideoStmt = db.prepare('DELETE FROM imm_videos WHERE video_id = ?');
for (const videoId of videoIds) {
deleteLinesStmt.run(videoId);
deleteDailyStmt.run(videoId);
deleteMonthlyStmt.run(videoId);
deleteArtStmt.run(videoId);
deleteVideoStmt.run(videoId);
}
for (const coverBlobHash of new Set(coverBlobHashes)) {
cleanupUnusedCoverArtBlobHash(db, coverBlobHash);
}
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(animeId);
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
export function deleteVideo(db: DatabaseSync, videoId: number): void {
db.exec('BEGIN IMMEDIATE');
try {
const artRow = db
.prepare(
`
@@ -530,14 +592,11 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
`,
)
.get(videoId) as { coverBlobHash: string | null } | undefined;
const affectedWordIds = getAffectedWordIdsForVideo(db, videoId);
const affectedKanjiIds = getAffectedKanjiIdsForVideo(db, videoId);
const lexicalRemovals = planLexicalRemovalsForVideos(db, [videoId]);
const sessions = db
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
.all(videoId) as Array<{ session_id: number }>;
db.exec('BEGIN IMMEDIATE');
try {
deleteSessionsByIds(
db,
sessions.map((session) => session.session_id),
@@ -548,7 +607,7 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
db.prepare('DELETE FROM imm_media_art WHERE video_id = ?').run(videoId);
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
db.exec('COMMIT');
} catch (error) {
@@ -203,6 +203,167 @@ export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number):
return getAffectedIdsForVideo(db, 'kanji', videoId);
}
/** Per-entity totals that a pending delete is about to remove. */
interface LexicalRemoval {
id: number;
removedFrequency: number;
removedFirstSeenMs: number | null;
removedLastSeenMs: number | null;
}
/** What a pending delete removes from `imm_words` and `imm_kanji`. */
export interface LexicalRemovalPlan {
words: LexicalRemoval[];
kanji: LexicalRemoval[];
}
export const EMPTY_LEXICAL_REMOVAL_PLAN: LexicalRemovalPlan = { words: [], kanji: [] };
function collectLexicalRemovals(
db: DatabaseSync,
entity: LexicalEntity,
lineScopeSql: string,
params: number[],
): LexicalRemoval[] {
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
return db
.prepare(
`SELECT
o.${col} AS id,
COALESCE(SUM(o.occurrence_count), 0) AS removedFrequency,
MIN(COALESCE(o.seen_ms, sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS removedFirstSeenMs,
MAX(COALESCE(o.seen_ms, sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS removedLastSeenMs
FROM imm_subtitle_lines sl
JOIN ${table} o ON o.line_id = sl.line_id
WHERE ${lineScopeSql}
GROUP BY o.${col}`,
)
.all(...params) as LexicalRemoval[];
}
function planLexicalRemovals(
db: DatabaseSync,
lineScopeSql: string,
params: number[],
): LexicalRemovalPlan {
return {
words: collectLexicalRemovals(db, 'word', lineScopeSql, params),
kanji: collectLexicalRemovals(db, 'kanji', lineScopeSql, params),
};
}
/**
* Measure what deleting these sessions removes from the vocabulary tables.
*
* Must run before the rows are deleted. Reads only the subtitle lines in scope,
* unlike {@link refreshLexicalAggregates}, which re-reads every occurrence of
* every affected word across the whole library.
*/
export function planLexicalRemovalsForSessions(
db: DatabaseSync,
sessionIds: number[],
): LexicalRemovalPlan {
if (sessionIds.length === 0) return EMPTY_LEXICAL_REMOVAL_PLAN;
return planLexicalRemovals(db, `sl.session_id IN (${makePlaceholders(sessionIds)})`, sessionIds);
}
/** Measure what deleting these videos removes from the vocabulary tables. */
export function planLexicalRemovalsForVideos(
db: DatabaseSync,
videoIds: number[],
): LexicalRemovalPlan {
if (videoIds.length === 0) return EMPTY_LEXICAL_REMOVAL_PLAN;
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
}
function toStoredSeenSeconds(ms: number | null): number | null {
if (ms === null || !Number.isFinite(ms)) return null;
return Math.floor(ms / 1000);
}
/**
* Apply a removal plan to the vocabulary aggregates.
*
* Frequencies are adjusted by subtraction, which is exact and touches only the
* affected rows. `first_seen`/`last_seen` only need a rescan when the removed
* lines held the current extreme, and rows whose frequency reaches zero are
* verified against the surviving occurrences before deletion so stored counts
* that have drifted still converge on the truth instead of dropping a live row.
*/
export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan): void {
applyRemovalsForEntity(db, 'word', plan.words);
applyRemovalsForEntity(db, 'kanji', plan.kanji);
}
function applyRemovalsForEntity(
db: DatabaseSync,
entity: LexicalEntity,
removals: LexicalRemoval[],
): void {
if (removals.length === 0) return;
const entityTable = entity === 'word' ? 'imm_words' : 'imm_kanji';
const occurrenceTable =
entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
const selectStmt = db.prepare(
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen
FROM ${entityTable}
WHERE id = ?`,
);
const updateFrequencyStmt = db.prepare(`UPDATE ${entityTable} SET frequency = ? WHERE id = ?`);
const hasOccurrencesStmt = db.prepare(
`SELECT 1 AS found FROM ${occurrenceTable} WHERE ${col} = ? LIMIT 1`,
);
const deleteStmt = db.prepare(`DELETE FROM ${entityTable} WHERE id = ?`);
const needsExactRefresh: number[] = [];
for (const removal of removals) {
const current = selectStmt.get(removal.id) as {
frequency: number | null;
firstSeen: number | null;
lastSeen: number | null;
} | null;
if (!current) continue;
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
if (nextFrequency <= 0) {
// The rows in scope are already gone by now, so anything still pointing at
// this entity means the stored frequency was stale rather than exhausted.
if (hasOccurrencesStmt.get(removal.id)) {
needsExactRefresh.push(removal.id);
} else {
deleteStmt.run(removal.id);
}
continue;
}
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
const firstSeenMayHaveMoved =
current.firstSeen === null ||
(removedFirstSeen !== null && removedFirstSeen <= current.firstSeen);
const lastSeenMayHaveMoved =
current.lastSeen === null ||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
needsExactRefresh.push(removal.id);
continue;
}
updateFrequencyStmt.run(nextFrequency, removal.id);
}
if (entity === 'word') {
refreshWordAggregates(db, needsExactRefresh);
} else {
refreshKanjiAggregates(db, needsExactRefresh);
}
}
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
if (wordIds.length === 0) {
return;
@@ -214,11 +375,18 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
SELECT
w.id AS wordId,
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
MIN(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS firstSeen,
MAX(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS lastSeen
FROM imm_words w
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE w.id IN (${makePlaceholders(wordIds)})
GROUP BY w.id
`,
@@ -263,11 +431,18 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
SELECT
k.id AS kanjiId,
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
MIN(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS firstSeen,
MAX(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS lastSeen
FROM imm_kanji k
LEFT JOIN imm_kanji_line_occurrences o ON o.kanji_id = k.id
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE k.id IN (${makePlaceholders(kanjiIds)})
GROUP BY k.id
`,
+56 -12
View File
@@ -192,6 +192,33 @@ function addColumnIfMissing(
}
}
/**
* Copy each subtitle line's timestamp onto its word/kanji occurrence rows.
*
* Vocabulary aggregates used to be recomputed by joining every occurrence back
* to `imm_subtitle_lines` just to read two timestamps, which meant one random
* read into the widest table per occurrence the reason deleting a session cost
* seconds on a large library. With the timestamp stored alongside the count, the
* covering index answers those aggregates on its own.
*/
function backfillLexicalOccurrenceSeenMs(db: DatabaseSync): void {
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
addColumnIfMissing(db, table, 'seen_ms', 'INTEGER');
db.exec(`
UPDATE ${table}
SET seen_ms = (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = ${table}.line_id
)
WHERE seen_ms IS NULL
`);
}
// Superseded by the covering (entity, seen_ms, occurrence_count, line_id) indexes.
db.exec('DROP INDEX IF EXISTS idx_word_line_occurrences_word');
db.exec('DROP INDEX IF EXISTS idx_kanji_line_occurrences_kanji');
}
function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: string): void {
if (hasColumn(db, tableName, columnName)) {
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
@@ -923,6 +950,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -933,6 +961,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -1093,6 +1122,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -1103,6 +1133,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -1349,13 +1380,19 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_subtitle_lines_anime_line
ON imm_subtitle_lines(anime_id, line_index)
`);
if (currentVersion?.schema_version && currentVersion.schema_version < 19) {
backfillLexicalOccurrenceSeenMs(db);
}
// Covering indexes: vocabulary aggregates (frequency, first/last seen) are
// answered from the index alone, without reading the wide subtitle-line rows.
db.exec(`
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word
ON imm_word_line_occurrences(word_id, line_id)
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word_seen
ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji
ON imm_kanji_line_occurrences(kanji_id, line_id)
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji_seen
ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_media_art_cover_blob_hash
@@ -1475,21 +1512,23 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
`),
wordLineOccurrenceUpsertStmt: db.prepare(`
INSERT INTO imm_word_line_occurrences (
line_id, word_id, occurrence_count
line_id, word_id, occurrence_count, seen_ms
) VALUES (
?, ?, ?
?, ?, ?, ?
)
ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(imm_word_line_occurrences.seen_ms, excluded.seen_ms)
`),
kanjiLineOccurrenceUpsertStmt: db.prepare(`
INSERT INTO imm_kanji_line_occurrences (
line_id, kanji_id, occurrence_count
line_id, kanji_id, occurrence_count, seen_ms
) VALUES (
?, ?, ?
?, ?, ?, ?
)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
occurrence_count = imm_kanji_line_occurrences.occurrence_count + excluded.occurrence_count
occurrence_count = imm_kanji_line_occurrences.occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(imm_kanji_line_occurrences.seen_ms, excluded.seen_ms)
`),
videoAnimeIdSelectStmt: db.prepare(`
SELECT anime_id FROM imm_videos
@@ -1630,11 +1669,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
const lineId = Number(lineResult.lastInsertRowid);
for (const occurrence of write.wordOccurrences) {
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount);
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
}
for (const occurrence of write.kanjiOccurrences) {
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount);
stmts.kanjiLineOccurrenceUpsertStmt.run(
lineId,
kanjiId,
occurrence.occurrenceCount,
currentMs,
);
}
return;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 18;
export const SCHEMA_VERSION = 19;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
+28 -14
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import type { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
@@ -74,6 +75,31 @@ function detectImageContentType(bytes: Uint8Array): string {
return 'application/octet-stream';
}
function ifNoneMatchHits(header: string | undefined, etag: string): boolean {
if (!header) return false;
if (header.trim() === '*') return true;
return header
.split(',')
.map((candidate) => candidate.trim().replace(/^W\//, ''))
.some((candidate) => candidate === etag);
}
// Cover URLs are stable per anime/video, so relinking an AniList entry swaps the
// bytes behind an unchanged URL. Long max-age would keep serving the old art from
// the browser cache; revalidate instead and let the ETag skip the transfer.
function coverResponse(bytes: Uint8Array<ArrayBuffer>, ifNoneMatch: string | undefined): Response {
const etag = `"${createHash('sha256').update(bytes).digest('hex')}"`;
const headers: Record<string, string> = {
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'no-cache',
ETag: etag,
};
if (ifNoneMatchHits(ifNoneMatch, etag)) {
return new Response(null, { status: 304, headers });
}
return new Response(bytes, { headers });
}
function createLimitedTaskRunner(maxConcurrentTasks: number): (task: () => Promise<void>) => void {
const queue: Array<() => Promise<void>> = [];
let activeTasks = 0;
@@ -137,13 +163,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
art = await tracker.getAnimeCoverArt(animeId);
}
if (!art?.coverBlob) return c.body(null, 404);
const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: {
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=86400',
},
});
return coverResponse(new Uint8Array(art.coverBlob), c.req.header('if-none-match'));
});
app.get('/api/stats/media/:videoId/cover', async (c) => {
@@ -155,12 +175,6 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
art = await tracker.getCoverArt(videoId);
}
if (!art?.coverBlob) return c.body(null, 404);
const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: {
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=604800',
},
});
return coverResponse(new Uint8Array(art.coverBlob), c.req.header('if-none-match'));
});
}
@@ -190,4 +190,11 @@ export function registerStatsLibraryRoutes(
await tracker.deleteVideo(videoId);
return c.json(statsJson('deleteVideo', { ok: true }));
});
app.delete('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
await tracker.deleteAnime(animeId);
return c.json(statsJson('deleteAnime', { ok: true }));
});
}
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from '../immersion-tracker/sqlite';
import { ensureSchema } from '../immersion-tracker/storage';
import { mergeSnapshotIntoDb } from './merge';
const BASE_MS = 1_700_000_000_000;
const DAY_MS = 86_400_000;
/**
* Build a database holding one anime, one episode, one ended session and a
* single word occurrence.
*
* `legacyOccurrences` reproduces a peer whose schema predates the denormalised
* `seen_ms` column, so the merge has to recover the timestamp from the line.
*/
function buildDb(
dir: string,
name: string,
options: { word: string; seenMs: number; legacyOccurrences: boolean },
): string {
const dbPath = path.join(dir, name);
const db = new Database(dbPath);
ensureSchema(db);
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'key-${name}', 'Show ${name}', ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'video-${name}', 1, 'Episode 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'uuid-${name}', 1, '${options.seenMs}', '${options.seenMs + 1000}', 2, ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 1, 1, 1, 0, 'line', ${options.seenMs}, ${options.seenMs});
INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
VALUES (1, '${options.word}', '${options.word}', '', 'noun', '名詞',
${Math.floor(options.seenMs / 1000)}, ${Math.floor(options.seenMs / 1000)}, 1);
`);
db.exec(
options.legacyOccurrences
? 'INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count) VALUES (1, 1, 1)'
: `INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (1, 1, 1, ${options.seenMs})`,
);
db.close();
return dbPath;
}
function mergedOccurrences(dbPath: string): Array<{ word: string; seenMs: number | null }> {
const db = new Database(dbPath);
try {
return db
.prepare(
`SELECT w.word AS word, o.seen_ms AS seenMs
FROM imm_word_line_occurrences o
JOIN imm_words w ON w.id = o.word_id
ORDER BY w.word`,
)
.all() as Array<{ word: string; seenMs: number | null }>;
} finally {
db.close();
}
}
for (const legacyOccurrences of [false, true]) {
const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer';
test(`sync merge carries occurrence timestamps in from ${label}`, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-'));
try {
const local = buildDb(dir, 'local.sqlite', {
word: '猫',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remoteSeenMs = BASE_MS + 5 * DAY_MS;
const remote = buildDb(dir, 'remote.sqlite', {
word: '犬',
seenMs: remoteSeenMs,
legacyOccurrences,
});
mergeSnapshotIntoDb(local, remote);
const rows = mergedOccurrences(local);
assert.equal(rows.length, 2, 'local and remote occurrences both survive the merge');
for (const row of rows) {
assert.ok(row.seenMs, `${row.word} must carry a timestamp so aggregates stay index-only`);
}
assert.equal(
Number(rows.find((row) => row.word === '犬')?.seenMs),
remoteSeenMs,
"the remote line's own timestamp is preserved rather than stamped with merge time",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
+13 -6
View File
@@ -215,12 +215,16 @@ function copySubtitleLines(
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
);
const insertWordOccurrence = local.query(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)
ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(seen_ms, excluded.seen_ms)`,
);
const insertKanjiOccurrence = local.query(
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
occurrence_count = occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(seen_ms, excluded.seen_ms)`,
);
for (const row of rows) {
@@ -241,17 +245,20 @@ function copySubtitleLines(
],
);
summary.subtitleLinesAdded += 1;
// Taken from the line rather than the remote occurrence row so this also
// works when the remote database predates the seen_ms column.
const seenMs = row.CREATED_DATE ?? row.LAST_UPDATE_DATE ?? null;
for (const occurrence of wordOccurrences.all(row.line_id) as SqlRow[]) {
const localWordId = lexicon.resolveWord(Number(occurrence.word_id));
const count = Number(occurrence.occurrence_count);
insertWordOccurrence.run(localLineId, localWordId, count);
insertWordOccurrence.run(localLineId, localWordId, count, seenMs);
lexicon.addWordOccurrences(Number(occurrence.word_id), count);
}
for (const occurrence of kanjiOccurrences.all(row.line_id) as SqlRow[]) {
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
const count = Number(occurrence.occurrence_count);
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
insertKanjiOccurrence.run(localLineId, localKanjiId, count, seenMs);
lexicon.addKanjiOccurrences(Number(occurrence.kanji_id), count);
}
}
+11 -4
View File
@@ -3616,10 +3616,12 @@ const {
);
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
mediaId,
logInfo: (message) => logger.info(message),
}),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
@@ -3655,9 +3657,12 @@ const {
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
enqueueRetry: (key, title, episode, season) => {
anilistUpdateQueue.enqueue(key, title, episode, season);
enqueueRetry: (key, title, episode, season, mediaId) => {
anilistUpdateQueue.enqueue(key, title, episode, season, mediaId);
},
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolvePinnedAnilistMediaId: ({ mediaPath, mediaTitle, guess }) =>
characterDictionaryRuntime.resolvePinnedMediaId({ mediaPath, mediaTitle, guess }),
markRetryFailure: (key, message) => {
anilistUpdateQueue.markFailure(key, message);
},
@@ -3665,10 +3670,12 @@ const {
anilistUpdateQueue.markSuccess(key);
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
mediaId,
logInfo: (message) => logger.info(message),
}),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
+29 -3
View File
@@ -36,6 +36,7 @@ import {
import {
buildCharacterDictionarySeriesKey,
createCharacterDictionaryManualSelectionStore,
type CharacterDictionarySeriesKeyGuess,
} from './character-dictionary-runtime/manual-selection';
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
import { resolveJapaneseNameSplits } from './character-dictionary-runtime/name-split-resolver';
@@ -168,6 +169,11 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
targetPath?: string,
options?: CharacterDictionaryGenerateOptions,
) => Promise<CharacterDictionaryBuildResult>;
resolvePinnedMediaId: (input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: CharacterDictionarySeriesKeyGuess | null;
}) => Promise<number | null>;
} {
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const sleepMs = deps.sleep ?? sleep;
@@ -272,7 +278,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
: ''
}`,
);
const override = await manualSelectionStore.getOverride(seriesKey);
const override = await manualSelectionStore.getOverride(seriesKey, guessed.season);
if (override) {
deps.logInfo?.(
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
@@ -314,7 +320,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
}
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest, (message) =>
deps.logInfo?.(message),
);
if (resolved.seasonResolved === false) {
deps.logWarn?.(
`[dictionary] could not find season ${resolved.requestedSeason} of "${guessed.title}"; using ${resolved.title} (AniList ${resolved.id}). Set a manual AniList match to correct it.`,
);
// Caching the fallback would make the wrong season stick silently for every later
// episode, so leave it uncached and re-resolve (and re-warn) until it is corrected.
return resolved;
}
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: resolved.id,
@@ -509,7 +525,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
: [];
const [override, current] = await Promise.all([
manualSelectionStore.getOverride(seriesKey),
manualSelectionStore.getOverride(seriesKey, guessed.season),
shouldUseExplicitSearch
? Promise.resolve(null)
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
@@ -553,6 +569,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
mediaId: selected.id,
mediaTitle: selected.title,
staleMediaIds,
season: guessed.season,
});
return {
ok: true,
@@ -605,5 +622,14 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
revision,
};
},
resolvePinnedMediaId: async ({ mediaPath, mediaTitle, guess }) => {
const seriesKey = buildCharacterDictionarySeriesKey({
mediaPath: deps.resolveMediaPathForJimaku(mediaPath),
mediaTitle,
guess,
});
const override = await manualSelectionStore.getOverride(seriesKey, guess?.season ?? null);
return override?.mediaId ?? null;
},
};
}
+13 -74
View File
@@ -1,4 +1,5 @@
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { resolveAnilistSeasonMedia } from '../../core/services/anilist/season-resolver';
import { ANILIST_GRAPHQL_URL } from './constants';
import type {
AniListMediaCandidate,
@@ -73,57 +74,6 @@ type AniListCharacterPageResponse = {
} | null;
};
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
function pickAniListSearchResult(
title: string,
episode: number | null,
media: Array<{
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>,
): ResolvedAniListMedia | null {
if (media.length === 0) return null;
const episodeFiltered =
episode && episode > 0
? media.filter((entry) => {
const totalEpisodes = entry.episodes;
return (
typeof totalEpisodes !== 'number' || totalEpisodes <= 0 || episode <= totalEpisodes
);
})
: media;
const candidates = episodeFiltered.length > 0 ? episodeFiltered : media;
const normalizedTitle = normalizeTitle(title);
const exact = candidates.find((entry) => {
const titles = [entry.title?.english, entry.title?.romaji, entry.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
return titles.includes(normalizedTitle);
});
const selected = exact ?? candidates[0] ?? media[0];
if (!selected) return null;
const selectedTitle =
selected.title?.english?.trim() ||
selected.title?.romaji?.trim() ||
selected.title?.native?.trim() ||
title.trim();
return {
id: selected.id,
title: selectedTitle,
};
}
function toAniListMediaCandidate(
entry: {
id: number;
@@ -242,35 +192,24 @@ function inferImageExt(contentType: string | null, bytes: Buffer): string {
export async function resolveAniListMediaIdFromGuess(
guess: AnilistMediaGuess,
beforeRequest?: () => Promise<void>,
logInfo?: (message: string) => void,
): Promise<ResolvedAniListMedia> {
const data = await fetchAniList<AniListSearchResponse>(
`
query($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
const resolution = await resolveAnilistSeasonMedia(
{ title: guess.title, season: guess.season, episode: guess.episode },
{
search: guess.title,
execute: (query, variables) => fetchAniList(query, variables, beforeRequest),
logInfo,
},
beforeRequest,
);
const media = data.Page?.media ?? [];
const resolved = pickAniListSearchResult(guess.title, guess.episode, media);
if (!resolved) {
if (!resolution) {
throw new Error(`No AniList media match found for "${guess.title}".`);
}
return resolved;
return {
id: resolution.id,
title: resolution.title,
seasonResolved: resolution.seasonResolved,
requestedSeason: resolution.requestedSeason,
};
}
export async function searchAniListMediaCandidates(
@@ -160,3 +160,73 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c
globalThis.fetch = originalFetch;
}
});
test('resolvePinnedMediaId returns the manual override for the current season', async () => {
const userDataPath = makeTempDir();
const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv';
const guess = {
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
season: 3,
episode: 1,
source: 'guessit' as const,
};
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => seasonThreePath,
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => guess,
now: () => 1_700_000_000_000,
});
assert.equal(
await runtime.resolvePinnedMediaId({
mediaPath: seasonThreePath,
mediaTitle: null,
guess,
}),
null,
);
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
fs.writeFileSync(
overridesPath,
JSON.stringify({
overrides: [
{
seriesKey: buildCharacterDictionarySeriesKey({
mediaPath: seasonThreePath,
mediaTitle: null,
guess,
}),
mediaId: 108489,
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
staleMediaIds: [],
},
],
}),
'utf8',
);
assert.equal(
await runtime.resolvePinnedMediaId({
mediaPath: seasonThreePath,
mediaTitle: null,
guess,
}),
108489,
);
// The season 1 file in the same folder must not inherit the season 3 override.
assert.equal(
await runtime.resolvePinnedMediaId({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
mediaTitle: null,
guess: { ...guess, season: 1 },
}),
null,
);
});
@@ -312,3 +312,131 @@ test('manual selection store keeps overrides separate for different season direc
assert.notEqual(secondSeasonKey, firstSeasonKey);
assert.equal(await store.getOverride(secondSeasonKey), null);
});
test('buildCharacterDictionarySeriesKey tags seasons past the first', () => {
const base = {
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
episode: 1,
source: 'guessit' as const,
};
const seasonOne = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
mediaTitle: null,
guess: { ...base, season: 1 },
});
const seasonThree = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
mediaTitle: null,
guess: { ...base, season: 3 },
});
// Season 1 keeps the pre-existing key shape so cached snapshots stay valid.
assert.equal(seasonOne, 'anime-oregairu--my-teen-romantic-comedy-snafu-2013');
assert.equal(seasonThree, 'anime-oregairu--my-teen-romantic-comedy-snafu-s3-2013');
});
test('manual selection store keeps seasons apart inside one flat directory', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const base = {
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
episode: 1,
source: 'guessit' as const,
};
const seasonOneKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
mediaTitle: null,
guess: { ...base, season: 1 },
});
const seasonThreeKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
mediaTitle: null,
guess: { ...base, season: 3 },
});
await store.setOverride({
seriesKey: seasonOneKey,
mediaId: 14813,
mediaTitle: 'My Teen Romantic Comedy SNAFU',
staleMediaIds: [],
});
// Same directory, different season: the season 1 override must not leak across.
assert.equal(await store.getOverride(seasonThreeKey), null);
await store.setOverride({
seriesKey: seasonThreeKey,
mediaId: 108489,
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
staleMediaIds: [],
});
assert.equal((await store.getOverride(seasonOneKey))?.mediaId, 14813);
assert.equal((await store.getOverride(seasonThreeKey))?.mediaId, 108489);
});
test('override scope uses the recorded season, not season-like text in the title', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
// A title that itself normalizes to a trailing "-s2" would be misparsed as season 2.
const trickyTitleKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Mixed/Some Show S2 - S01E01.mkv',
mediaTitle: null,
guess: { title: 'Some Show S2', season: 1, episode: 1, source: 'guessit' },
});
const realSeasonTwoKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Mixed/Other Show - S02E01.mkv',
mediaTitle: null,
guess: { title: 'Other Show', season: 2, episode: 1, source: 'guessit' },
});
await store.setOverride({
seriesKey: trickyTitleKey,
mediaId: 111,
mediaTitle: 'Some Show S2',
staleMediaIds: [],
season: 1,
});
await store.setOverride({
seriesKey: realSeasonTwoKey,
mediaId: 222,
mediaTitle: 'Other Show 2',
staleMediaIds: [],
season: 2,
});
// Same directory, genuinely different seasons: neither override may replace the other.
assert.equal((await store.getOverride(trickyTitleKey, 1))?.mediaId, 111);
assert.equal((await store.getOverride(realSeasonTwoKey, 2))?.mediaId, 222);
});
test('override records without a stored season still resolve via the key', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const legacyKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
fs.writeFileSync(
overridesPath,
JSON.stringify({
overrides: [
{ seriesKey: legacyKey, mediaId: 21355, mediaTitle: 'Re:ZERO', staleMediaIds: [] },
],
}),
'utf8',
);
assert.equal((await store.getOverride(legacyKey, 1))?.mediaId, 21355);
});
@@ -8,6 +8,11 @@ export type CharacterDictionaryManualSelection = {
mediaId: number;
mediaTitle: string;
staleMediaIds: number[];
/**
* Season this override was saved for. Recorded explicitly because inferring it from the
* key text is ambiguous for titles that themselves end in a season-like token.
*/
season?: number | null;
};
type ManualSelectionStoreFile = {
@@ -20,6 +25,11 @@ function normalizeManualMediaId(value: unknown): number | null {
return mediaId > 0 ? mediaId : null;
}
function normalizeSeason(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null;
return value;
}
function normalizeSeriesKeyPart(value: string): string {
return value
.normalize('NFKD')
@@ -73,11 +83,13 @@ function normalizeOverride(value: unknown): CharacterDictionaryManualSelection |
const mediaId = normalizeManualMediaId(raw.mediaId);
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (!seriesKey || mediaId === null || !mediaTitle) return null;
const season = normalizeSeason(raw.season);
return {
seriesKey,
mediaId,
mediaTitle,
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
...(season === null ? {} : { season }),
};
}
@@ -112,10 +124,29 @@ function getDirectoryScope(seriesKey: string): string | null {
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
}
/**
* Fallback season for override records saved before the season was stored explicitly:
* reads the "-s<N>" segment emitted by buildCharacterDictionarySeriesKey, which sits just
* before the optional trailing year. Season 1 keys carry no segment and parse as 1.
*/
function getSeasonScopeFromKey(seriesKey: string): number {
const match = /-s(\d{1,2})(?:-\d{4})?$/.exec(seriesKey);
if (!match) return 1;
const season = Number.parseInt(match[1]!, 10);
return Number.isInteger(season) && season > 0 ? season : 1;
}
/**
* The subset of a parsed guess the series key is built from. Kept structural so callers
* holding a narrower guess (the AniList post-watch runtime) can build the same key.
*/
export type CharacterDictionarySeriesKeyGuess = Pick<AnilistMediaGuess, 'title' | 'season'> &
Partial<Omit<AnilistMediaGuess, 'title' | 'season'>>;
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: AnilistMediaGuess | null;
guess: CharacterDictionarySeriesKeyGuess | null;
}): string {
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
const sourceTitle =
@@ -130,22 +161,40 @@ export function buildCharacterDictionarySeriesKey(input: {
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
const directoryKey = getMediaDirectoryKey(input.mediaPath);
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
// Season 1 stays unsuffixed so existing keys, snapshots and overrides keep matching.
const season = input.guess?.season;
const seasonSegment =
typeof season === 'number' && Number.isInteger(season) && season > 1 ? `-s${season}` : '';
const withSeason = `${scopedBase}${seasonSegment}`;
return input.guess?.year ? `${withSeason}-${input.guess.year}` : withSeason;
}
/** Season an override applies to: the recorded value, else parsed from its key. */
function getOverrideSeason(entry: CharacterDictionaryManualSelection): number {
return normalizeSeason(entry.season) ?? getSeasonScopeFromKey(entry.seriesKey);
}
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
return {
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
getOverride: async (
seriesKey: string,
season?: number | null,
): Promise<CharacterDictionaryManualSelection | null> => {
const candidates = getLegacySeriesKeyCandidates(seriesKey);
const overrides = readOverrides(filePath);
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
if (exactMatch) return exactMatch;
const directoryScope = getDirectoryScope(seriesKey);
if (directoryScope) {
// Same folder is only evidence of the same show when it is also the same season;
// a flat multi-season folder would otherwise spread one override across all of them.
const seasonScope = normalizeSeason(season) ?? getSeasonScopeFromKey(seriesKey);
const scopedMatches = overrides.filter(
(entry) => getDirectoryScope(entry.seriesKey) === directoryScope,
(entry) =>
getDirectoryScope(entry.seriesKey) === directoryScope &&
getOverrideSeason(entry) === seasonScope,
);
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
@@ -168,9 +217,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
throw new Error('Invalid character dictionary manual selection.');
}
const directoryScope = getDirectoryScope(normalized.seriesKey);
const seasonScope = getOverrideSeason(normalized);
const remaining = readOverrides(filePath).filter((entry) =>
directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope ||
getOverrideSeason(entry) !== seasonScope
: entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
@@ -324,3 +324,102 @@ test('generateForCurrentMedia keeps same-version snapshots without images when i
globalThis.fetch = originalFetch;
}
});
test('an unresolvable season is not cached as a normal AniList match', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
let searchCalls = 0;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url !== GRAPHQL_URL) {
return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } });
}
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
variables?: { search?: string; id?: number };
};
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: { english: 'My Teen Romantic Comedy SNAFU' },
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (typeof body.variables?.id === 'number') {
// No sequel edges: season 3 is unreachable from the season 1 anchor.
return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
searchCalls += 1;
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 14813,
episodes: 13,
format: 'TV',
seasonYear: 2013,
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}) as typeof globalThis.fetch;
const warnings: string[] = [];
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () =>
'/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
season: 3,
episode: 1,
source: 'guessit',
}),
now: () => 1_700_000_000_000,
sleep: async () => {},
logWarn: (message) => warnings.push(message),
});
await runtime.getOrCreateCurrentSnapshot();
const searchesAfterFirst = searchCalls;
await runtime.getOrCreateCurrentSnapshot();
// Re-resolved rather than served from the resolution cache, and warned both times.
assert.ok(searchCalls > searchesAfterFirst);
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -181,4 +181,7 @@ export type ResolvedAniListMedia = {
id: number;
title: string;
staleMediaIds?: number[];
/** False when a season >= 2 was requested but only the season 1 entry could be found. */
seasonResolved?: boolean;
requestedSeason?: number | null;
};
@@ -24,7 +24,8 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
title: string,
episode: number,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
mediaId?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
markSuccess: (key: string) => deps.markSuccess(key),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
markFailure: (key: string, message: string) => deps.markFailure(key, message),
@@ -52,8 +53,19 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
deps.enqueueRetry(key, title, episode, season),
enqueueRetry: (
key: string,
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => deps.enqueueRetry(key, title, episode, season, mediaId),
getCurrentMediaTitle: deps.getCurrentMediaTitle
? () => deps.getCurrentMediaTitle!()
: undefined,
resolvePinnedAnilistMediaId: deps.resolvePinnedAnilistMediaId
? (input) => deps.resolvePinnedAnilistMediaId!(input)
: undefined,
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
@@ -62,7 +74,8 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
title: string,
episode: number,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
mediaId?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
logInfo: (message: string) => deps.logInfo(message),
+152
View File
@@ -380,3 +380,155 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
assert.equal(calls.includes('mark-failure'), false);
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
});
test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => {
const updateArgs: Array<number | null | undefined> = [];
const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> =
[];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => {},
getCurrentMediaTitle: () => 'Show S03E01.mkv',
resolvePinnedAnilistMediaId: async (input) => {
pinInputs.push(input);
return 108489;
},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
updateArgs.push(mediaId);
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(updateArgs, [108489]);
// Resolved against the media this run captured, not whatever is playing now.
assert.equal(pinInputs.length, 1);
assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv');
assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv');
assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 });
});
test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => {
const enqueued: Array<number | null | undefined> = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => null,
enqueueRetry: (_key, _title, _episode, _season, mediaId) => {
enqueued.push(mediaId);
},
resolvePinnedAnilistMediaId: async () => 108489,
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(enqueued, [108489]);
});
test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => {
const updateArgs: Array<number | null | undefined> = [];
const warnings: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => {},
resolvePinnedAnilistMediaId: async () => {
throw new Error('store unreadable');
},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
updateArgs.push(mediaId);
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: (message) => warnings.push(message),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(updateArgs, [null]);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /override lookup failed/i);
});
test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => {
const received: Array<number | null | undefined> = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }),
refreshRetryQueueState: () => {},
setLastAttemptAt: () => {},
setLastError: () => {},
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
received.push(mediaId);
return { status: 'updated', message: 'ok' };
},
markSuccess: () => {},
rememberAttemptedUpdateKey: () => {},
markFailure: () => {},
logInfo: () => {},
now: () => 1,
});
await handler();
assert.deepEqual(received, [108489]);
});
+42 -3
View File
@@ -4,6 +4,8 @@ type AnilistGuess = {
title: string;
season: number | null;
episode: number | null;
alternativeTitle?: string;
year?: number;
};
type AnilistUpdateResult = {
@@ -16,6 +18,7 @@ type RetryQueueItem = {
key: string;
title: string;
season?: number | null;
mediaId?: number | null;
episode: number;
};
@@ -58,6 +61,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => Promise<AnilistUpdateResult>;
markSuccess: (key: string) => void;
rememberAttemptedUpdateKey: (key: string) => void;
@@ -84,6 +88,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
queued.title,
queued.episode,
queued.season ?? null,
queued.mediaId ?? null,
);
if (result.status === 'updated' || result.status === 'skipped') {
deps.markSuccess(queued.key);
@@ -119,7 +124,23 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
hasAttemptedUpdateKey: (key: string) => boolean;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
refreshAnilistClientSecretState: () => Promise<string | null>;
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
enqueueRetry: (
key: string,
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => void;
getCurrentMediaTitle?: () => string | null;
/**
* Pinned AniList media id from a manual dictionary override. Takes the media captured
* by this run, so a mid-run file change cannot pin the update to a different show.
*/
resolvePinnedAnilistMediaId?: (input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: AnilistGuess;
}) => Promise<number | null>;
markRetryFailure: (key: string, message: string) => void;
markRetrySuccess: (key: string) => void;
refreshRetryQueueState: () => void;
@@ -128,6 +149,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => Promise<AnilistUpdateResult>;
rememberAttemptedUpdateKey: (key: string) => void;
showMpvOsd: (message: string) => void;
@@ -157,6 +179,8 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
if (deps.getTrackedMediaKey() !== mediaKey) {
deps.resetTrackedMedia(mediaKey);
}
// Captured before any await: playback can advance while the update is in flight.
const mediaTitle = deps.getCurrentMediaTitle?.() ?? null;
let watchedSeconds = 0;
if (!force) {
@@ -202,9 +226,23 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
// A manual dictionary override is the user's explicit answer to "which AniList
// entry is this?", so it wins over anything the title search would resolve.
let pinnedMediaId: number | null = null;
try {
pinnedMediaId =
(await deps.resolvePinnedAnilistMediaId?.({
mediaPath: mediaKey,
mediaTitle,
guess,
})) ?? null;
} catch (error) {
deps.logWarn(`AniList override lookup failed: ${String(error)}`);
}
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
deps.refreshRetryQueueState();
deps.showMpvOsd('AniList: access token not configured');
@@ -216,6 +254,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
guess.title,
guess.episode,
guess.season,
pinnedMediaId,
);
if (result.status === 'updated') {
deps.rememberAttemptedUpdateKey(attemptKey);
@@ -241,7 +280,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
deps.markRetryFailure(attemptKey, result.message);
deps.refreshRetryQueueState();
deps.showMpvOsd(`AniList: ${result.message}`);
+2
View File
@@ -141,6 +141,7 @@ export interface StatsJsonResponseMap {
deleteSessions: StatsOkResponse;
deleteSession: StatsOkResponse;
deleteVideo: StatsOkResponse;
deleteAnime: StatsOkResponse;
anilistSearch: StatsAnilistSearchResult[];
knownWords: string[];
knownWordsSummary: StatsKnownWordsSummary;
@@ -219,6 +220,7 @@ export interface StatsHttpClient {
deleteSession: (sessionId: number) => Promise<void>;
deleteSessions: (sessionIds: number[]) => Promise<void>;
deleteVideo: (videoId: number) => Promise<void>;
deleteAnime: (animeId: number) => Promise<void>;
getKnownWords: () => Promise<string[]>;
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
+4 -1
View File
@@ -1,8 +1,10 @@
import { Suspense, lazy, useCallback, useState } from 'react';
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
import { DeleteProgressToast } from './components/common/DeleteProgressToast';
import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab';
import { useExcludedWords } from './hooks/useExcludedWords';
import { assetUrl } from './lib/asset-url';
import type { TabId } from './components/layout/TabBar';
import {
closeMediaDetail,
@@ -140,7 +142,7 @@ export function App() {
onClick={() => handleTabChange('overview')}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
>
<img src="/favicon.png" alt="" className="h-6 object-contain" />
<img src={assetUrl('favicon.png')} alt="" className="h-6 object-contain" />
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
</button>
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
@@ -293,6 +295,7 @@ export function App() {
</Suspense>
) : null}
<DeleteConfirmDialog />
<DeleteProgressToast />
</div>
);
}
+53 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
import { confirmAnimeDelete } from '../../lib/delete-confirm';
import { epochDayToDate } from '../../lib/formatters';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
@@ -16,6 +17,14 @@ interface AnimeDetailViewProps {
onBack: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
/** Called after the whole library entry is deleted, so the caller can refresh. */
onAnimeDeleted?: () => void;
/**
* Called after the AniList link changes. The library list caches the old
* anilistId (and with it the cover URL), so it has to refetch or the grid
* keeps showing the previous title's art.
*/
onAnilistRelinked?: () => void;
}
type Range = 14 | 30 | 90;
@@ -139,10 +148,15 @@ export function AnimeDetailView({
onBack,
onNavigateToWord,
onOpenEpisodeDetail,
onAnimeDeleted,
onAnilistRelinked,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const isDeletingAnimeRef = useRef(false);
const knownWordsSummary = useAnimeKnownWords(animeId);
useEffect(() => {
@@ -154,6 +168,40 @@ export function AnimeDetailView({
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data;
const handleDeleteAnime = async () => {
if (isDeletingAnimeRef.current) return;
isDeletingAnimeRef.current = true;
// Cleared up front so cancelling a retry doesn't leave the previous
// attempt's failure on screen.
setDeleteError(null);
let confirmed = false;
try {
confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
isDeletingAnimeRef.current = false;
return;
}
if (!confirmed) {
isDeletingAnimeRef.current = false;
return;
}
setDeleteError(null);
setIsDeletingAnime(true);
try {
await getStatsClient().deleteAnime(animeId);
onAnimeDeleted?.();
onBack();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.');
setIsDeletingAnime(false);
} finally {
isDeletingAnimeRef.current = false;
}
};
return (
<div className="space-y-4">
<button
@@ -168,7 +216,10 @@ export function AnimeDetailView({
anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)}
onDeleteAnime={() => void handleDeleteAnime()}
isDeletingAnime={isDeletingAnime}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
@@ -185,6 +236,7 @@ export function AnimeDetailView({
setShowAnilistSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
onAnilistRelinked?.();
}}
/>
)}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeHeader } from './AnimeHeader';
import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete-confirm';
import type { AnimeDetailData } from '../../types/stats';
const DETAIL: AnimeDetailData['detail'] = {
animeId: 3,
canonicalTitle: 'Project Radio Noise Season 2',
anilistId: 20661,
titleRomaji: 'Toaru Kagaku no Railgun S',
titleEnglish: 'A Certain Scientific Railgun S',
titleNative: null,
description: null,
totalSessions: 1,
totalActiveMs: 960_000,
totalCards: 0,
totalTokensSeen: 1_655,
totalLinesSeen: 300,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 1_700_000_000_000,
};
test('AnimeHeader offers a delete-entry action alongside the AniList actions', () => {
const markup = renderToStaticMarkup(
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} />,
);
assert.match(markup, /Delete Entry/);
assert.match(markup, /Delete this title and every session and stat recorded for it/);
});
test('AnimeHeader hides the delete action when no handler is wired', () => {
const markup = renderToStaticMarkup(<AnimeHeader detail={DETAIL} anilistEntries={[]} />);
assert.doesNotMatch(markup, /Delete Entry/);
});
test('AnimeHeader shows a pending state while the entry is being deleted', () => {
const markup = renderToStaticMarkup(
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} isDeletingAnime />,
);
assert.match(markup, /disabled=""/);
assert.match(markup, /animate-spin/);
assert.doesNotMatch(markup, /Delete Entry/);
});
test('confirmAnimeDelete spells out how much data the entry deletion removes', async () => {
const seen: string[] = [];
const restore = setDeleteConfirmPresenter((message) => {
seen.push(message);
return false;
});
try {
await confirmAnimeDelete('Railgun Season 2', 1);
await confirmAnimeDelete('Railgun Season 2', 3);
} finally {
restore();
}
assert.match(seen[0] ?? '', /"Railgun Season 2"/);
assert.match(seen[0] ?? '', /1 episode\b/);
assert.match(seen[1] ?? '', /3 episodes/);
assert.match(seen[0] ?? '', /every session and stat/);
});
@@ -6,6 +6,8 @@ interface AnimeHeaderProps {
anilistEntries: AnilistEntry[];
coverRetryToken?: number;
onChangeAnilist?: () => void;
onDeleteAnime?: () => void;
isDeletingAnime?: boolean;
}
function AnilistButton({ entry }: { entry: AnilistEntry }) {
@@ -32,6 +34,8 @@ export function AnimeHeader({
anilistEntries,
coverRetryToken = 0,
onChangeAnilist,
onDeleteAnime,
isDeletingAnime = false,
}: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle,
@@ -95,6 +99,25 @@ export function AnimeHeader({
: 'Link to AniList'}
</button>
)}
{onDeleteAnime && (
<button
type="button"
onClick={onDeleteAnime}
disabled={isDeletingAnime}
title="Delete this title and every session and stat recorded for it"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-red/80 hover:bg-ctp-red/15 hover:text-ctp-red transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeletingAnime ? (
<span
aria-hidden="true"
className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
) : (
<span aria-hidden="true">{'\u2715'}</span>
)}
{isDeletingAnime ? 'Deleting\u2026' : 'Delete Entry'}
</button>
)}
</div>
{detail.description && (
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeDetailData, AnimeLibraryItem } from '../../types/stats';
import { AnimeTab } from './AnimeTab';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousIsReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
function libraryItem(anilistId: number | null): AnimeLibraryItem {
return {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
episodeCount: 1,
episodesTotal: 13,
lastWatchedMs: 1,
};
}
function detailData(anilistId: number | null): AnimeDetailData {
return {
detail: {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
totalLinesSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 1,
},
episodes: [],
anilistEntries: [],
};
}
function findButton(container: Element, label: string): HTMLElement {
const match = [...container.querySelectorAll('button')].find((button) =>
(button.textContent ?? '').includes(label),
);
assert.ok(match, `expected a "${label}" button`);
return match as unknown as HTMLElement;
}
test('AnimeTab refetches the library after the AniList entry is relinked', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeDetail: apiClient.getAnimeDetail,
getAnimeWords: apiClient.getAnimeWords,
getAnimeRollups: apiClient.getAnimeRollups,
getAnimeKnownWordsSummary: apiClient.getAnimeKnownWordsSummary,
searchAnilist: apiClient.searchAnilist,
reassignAnimeAnilist: apiClient.reassignAnimeAnilist,
};
// The library grid keys its cover URL off anilistId, so a stale list keeps
// pointing at the previous entry's art.
let anilistId: number | null = 14813;
let libraryFetches = 0;
apiClient.getAnimeLibrary = (async () => {
libraryFetches += 1;
return [libraryItem(anilistId)];
}) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeDetail = (async () => detailData(anilistId)) as typeof apiClient.getAnimeDetail;
apiClient.getAnimeWords = (async () => []) as typeof apiClient.getAnimeWords;
apiClient.getAnimeRollups = (async () => []) as typeof apiClient.getAnimeRollups;
apiClient.getAnimeKnownWordsSummary = (async () => ({
totalUniqueWords: 0,
knownWordCount: 0,
})) as typeof apiClient.getAnimeKnownWordsSummary;
apiClient.searchAnilist = (async () => [
{
id: 108489,
episodes: 12,
season: null,
seasonYear: null,
description: null,
coverImage: null,
title: { romaji: 'Test Anime Kan', english: null, native: null },
},
]) as typeof apiClient.searchAnilist;
apiClient.reassignAnimeAnilist = (async (_animeId: number, info: { anilistId: number }) => {
anilistId = info.anilistId;
}) as typeof apiClient.reassignAnimeAnilist;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(<AnimeTab />);
});
assert.equal(libraryFetches, 1);
assert.match(
container.querySelector('img')?.getAttribute('src') ?? '',
/\/api\/stats\/anime\/7\/cover\?coverRetry=14813/,
);
await act(async () => {
findButton(container, 'Test Anime Season 2').click();
});
await act(async () => {
findButton(container, 'Change AniList Entry').click();
});
await act(async () => {
findButton(container, 'Test Anime Kan').click();
});
await act(async () => {
findButton(container, 'Back to Library').click();
});
assert.equal(libraryFetches, 2);
assert.match(
container.querySelector('img')?.getAttribute('src') ?? '',
/\/api\/stats\/anime\/7\/cover\?coverRetry=108489/,
);
await act(async () => {
root.unmount();
});
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
+3 -1
View File
@@ -53,7 +53,7 @@ export function AnimeTab({
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const { anime, loading, error, reload } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
@@ -98,6 +98,8 @@ export function AnimeTab({
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
onAnimeDeleted={reload}
onAnilistRelinked={reload}
/>
);
}
@@ -1,32 +1,49 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
import { useSyncExternalStore } from 'react';
import { getDeleteProgressSnapshot, subscribeDeleteProgress } from '../../lib/delete-progress';
/**
* Fixed-position toast shown while session deletions are in flight.
* Global "deletion in progress" indicator.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
* Mounted once at the app root, outside every tab panel, so it shows no matter
* which view started the delete per-tab copies were hidden along with their
* `hidden` tab panel, and detail views had no indicator at all. State comes
* from the delete-progress store rather than props so it also survives the
* initiating component unmounting mid-request.
*
* Renders two signals: a sweeping bar pinned to the top edge (visible even when
* the eye is on the content being deleted) and a bottom-right toast naming the
* work.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
export function DeleteProgressToast() {
const { count, label } = useSyncExternalStore(
subscribeDeleteProgress,
getDeleteProgressSnapshot,
getDeleteProgressSnapshot,
);
if (count <= 0) return null;
const message = count > 1 ? `Deleting ${count} items` : (label ?? 'Deleting');
return (
<>
<div
aria-hidden="true"
className="fixed inset-x-0 top-0 z-[2147483646] h-0.5 overflow-hidden bg-ctp-surface1"
>
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-gradient-to-r from-ctp-red via-ctp-peach to-ctp-red" />
</div>
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
className="fixed bottom-4 right-4 z-[2147483646] flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
<span className="text-sm text-ctp-text">{message}&hellip;</span>
</div>
</>
);
}
@@ -6,7 +6,6 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
@@ -160,8 +159,6 @@ export function OverviewTab({
deletingIds={deletingIds}
isActive={isActive}
/>
<DeleteProgressToast count={deletingIds.size} />
</div>
);
}
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -344,8 +343,6 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
<DeleteProgressToast count={deletingSessionIds.size} />
</div>
);
}
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
import { assetUrl } from '../../lib/asset-url';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import {
buildStatsMineCardParams,
@@ -164,7 +165,10 @@ export function WordDetailPanel({
? data!.detail.headword
: occ.text.slice(0, 30);
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
new Notification('Anki Card Created', {
body: `Mined: ${label}`,
icon: assetUrl('favicon.png'),
});
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
Notification.requestPermission().then((p) => {
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
+8 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
@@ -6,6 +6,11 @@ export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const reload = useCallback(() => {
setReloadToken((token) => token + 1);
}, []);
useEffect(() => {
let cancelled = false;
@@ -23,7 +28,7 @@ export function useAnimeLibrary() {
return () => {
cancelled = true;
};
}, []);
}, [reloadToken]);
return { anime, loading, error };
return { anime, loading, error, reload };
}
+17 -4
View File
@@ -15,6 +15,7 @@ import type {
} from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
import { trackDelete } from './delete-progress';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -169,17 +170,29 @@ export const apiClient = {
});
},
deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
await trackDelete('Deleting session', () =>
fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }),
);
},
deleteSessions: async (sessionIds: number[]): Promise<void> => {
await fetchResponse('/api/stats/sessions', {
const label = `Deleting ${sessionIds.length} session${sessionIds.length === 1 ? '' : 's'}`;
await trackDelete(label, () =>
fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
});
}),
);
},
deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
await trackDelete('Deleting episode', () =>
fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }),
);
},
deleteAnime: async (animeId: number): Promise<void> => {
await trackDelete('Deleting library entry', () =>
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
);
},
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assetUrl, resolveAssetUrl } from './asset-url';
// vite.config.ts sets `base: './'`, so this is what the built bundle sees.
const BUILT_BASE = './';
test('built asset URLs are never root-absolute', () => {
assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false);
});
test('built asset URL resolves next to a file:// index.html', () => {
const resolved = new URL(
resolveAssetUrl('favicon.png', BUILT_BASE),
'file:///opt/SubMiner/stats/dist/index.html',
);
assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png');
});
test('built asset URL resolves against the server root when served over http', () => {
const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/');
assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png');
});
test('dev server base stays root-absolute', () => {
assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png');
});
test('a base without a trailing slash still joins cleanly', () => {
assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png');
});
test('a leading slash in the requested path is tolerated', () => {
assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png');
});
test('assetUrl falls back to a relative base outside a Vite bundle', () => {
assert.equal(assetUrl('favicon.png').startsWith('/'), false);
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Resolve a bundled public asset against Vite's base URL.
*
* The in-player stats window is loaded with `loadFile`, so the document lives on
* `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem
* root there and 404s, while the HTTP-served web app resolves it fine. Vite
* rewrites asset refs in `index.html` but not string literals in JSX, so build
* the URL from the configured base instead of hardcoding a leading slash.
*/
export function resolveAssetUrl(path: string, base: string): string {
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
return `${normalizedBase}${path.replace(/^\/+/, '')}`;
}
function currentBase(): string {
// Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/'
// in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'.
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
return env?.BASE_URL || './';
}
export function assetUrl(path: string): string {
return resolveAssetUrl(path, currentBase());
}
+7
View File
@@ -60,6 +60,13 @@ export function confirmAnimeGroupDelete(title: string, count: number): Promise<b
);
}
export function confirmAnimeDelete(title: string, episodeCount: number): Promise<boolean> {
const episodes = `${episodeCount} episode${episodeCount === 1 ? '' : 's'}`;
return confirmWithStatsNativeDialogLayer(
`Delete "${title}" from your library? This removes ${episodes} plus every session and stat recorded for them.`,
);
}
export function confirmEpisodeDelete(title: string): Promise<boolean> {
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
}
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { renderToStaticMarkup } from 'react-dom/server';
import { DeleteProgressToast } from '../components/common/DeleteProgressToast';
import { apiClient } from './api-client';
import {
beginDeleteTask,
getDeleteProgressSnapshot,
resetDeleteProgress,
subscribeDeleteProgress,
trackDelete,
} from './delete-progress';
test('delete progress store reports the oldest label and notifies subscribers', () => {
resetDeleteProgress();
let notifications = 0;
const unsubscribe = subscribeDeleteProgress(() => {
notifications += 1;
});
try {
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
const endFirst = beginDeleteTask('Deleting session');
const endSecond = beginDeleteTask('Deleting episode');
assert.deepEqual(getDeleteProgressSnapshot(), { count: 2, label: 'Deleting session' });
assert.equal(notifications, 2);
endFirst();
assert.deepEqual(getDeleteProgressSnapshot(), { count: 1, label: 'Deleting episode' });
endFirst();
assert.deepEqual(
getDeleteProgressSnapshot(),
{ count: 1, label: 'Deleting episode' },
'ending the same task twice must not drop another task',
);
endSecond();
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
} finally {
unsubscribe();
resetDeleteProgress();
}
});
test('trackDelete clears the indicator even when the request rejects', async () => {
resetDeleteProgress();
await assert.rejects(
trackDelete('Deleting library entry', async () => {
assert.equal(getDeleteProgressSnapshot().count, 1);
throw new Error('boom');
}),
/boom/,
);
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
});
test('DeleteProgressToast stays hidden while idle and reports active deletes', () => {
resetDeleteProgress();
assert.equal(renderToStaticMarkup(<DeleteProgressToast />), '');
const end = beginDeleteTask('Deleting library entry');
try {
const markup = renderToStaticMarkup(<DeleteProgressToast />);
assert.match(markup, /role="status"/);
assert.match(markup, /Deleting library entry/);
assert.match(markup, /animate-indeterminate/);
} finally {
end();
}
const endFirst = beginDeleteTask('Deleting session');
const endSecond = beginDeleteTask('Deleting episode');
try {
assert.match(renderToStaticMarkup(<DeleteProgressToast />), /Deleting 2 items/);
} finally {
endFirst();
endSecond();
resetDeleteProgress();
}
});
test('the delete indicator is mounted once at the app root, not inside tab panels', () => {
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relativePath: string): string =>
fs.readFileSync(path.join(srcDir, relativePath), 'utf8');
const app = read('App.tsx');
assert.match(app, /<DeleteProgressToast \/>/);
// Sibling of the confirm dialog: outside every `hidden` tab panel, so the
// indicator survives tab switches and detail-view navigation.
assert.match(app, /<DeleteConfirmDialog \/>\s*<DeleteProgressToast \/>/);
for (const tab of [
'components/overview/OverviewTab.tsx',
'components/sessions/SessionsTab.tsx',
]) {
assert.doesNotMatch(read(tab), /DeleteProgressToast/, `${tab} must not mount its own toast`);
}
});
test('every api client delete registers with the global progress indicator', async () => {
const originalFetch = globalThis.fetch;
const seenCounts: number[] = [];
globalThis.fetch = (async () => {
seenCounts.push(getDeleteProgressSnapshot().count);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as typeof globalThis.fetch;
try {
resetDeleteProgress();
await apiClient.deleteSession(1);
await apiClient.deleteSessions([1, 2]);
await apiClient.deleteVideo(3);
await apiClient.deleteAnime(4);
assert.deepEqual(seenCounts, [1, 1, 1, 1]);
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
} finally {
globalThis.fetch = originalFetch;
resetDeleteProgress();
}
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Tiny external store tracking in-flight delete requests.
*
* Every delete goes through the API client, which registers here, so a single
* globally mounted indicator can report progress no matter which tab, detail
* view or overlay window started the work. Keeping the state outside React
* means the indicator survives the deleting component unmounting mid-request
* (navigating back after deleting a library entry, for example).
*/
export interface DeleteProgressSnapshot {
/** Number of delete requests currently in flight. */
count: number;
/** Label for the oldest in-flight request, or null when idle. */
label: string | null;
}
const IDLE_SNAPSHOT: DeleteProgressSnapshot = { count: 0, label: null };
const activeTasks = new Map<number, string>();
const listeners = new Set<() => void>();
let nextTaskId = 1;
let snapshot: DeleteProgressSnapshot = IDLE_SNAPSHOT;
function publish(): void {
if (activeTasks.size === 0) {
snapshot = IDLE_SNAPSHOT;
} else {
const [oldestLabel] = activeTasks.values();
snapshot = { count: activeTasks.size, label: oldestLabel ?? null };
}
for (const listener of listeners) listener();
}
export function subscribeDeleteProgress(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getDeleteProgressSnapshot(): DeleteProgressSnapshot {
return snapshot;
}
/** Register a delete as started. Returns the function that ends it. */
export function beginDeleteTask(label: string): () => void {
const taskId = nextTaskId++;
activeTasks.set(taskId, label);
publish();
let ended = false;
return () => {
if (ended) return;
ended = true;
activeTasks.delete(taskId);
publish();
};
}
/** Run a delete request with the global progress indicator active. */
export async function trackDelete<T>(label: string, run: () => Promise<T>): Promise<T> {
const end = beginDeleteTask(label);
try {
return await run();
} finally {
end();
}
}
/** Test helper: drop every in-flight task so cases don't leak into each other. */
export function resetDeleteProgress(): void {
activeTasks.clear();
publish();
}
+36 -2
View File
@@ -80,7 +80,7 @@ body.overlay-mode #root {
}
/* Tab content entrance animation */
@keyframes fadeSlideIn {
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(6px);
@@ -92,5 +92,39 @@ body.overlay-mode #root {
}
.animate-fade-in {
animation: fadeSlideIn 0.25s ease-out;
animation: fade-slide-in 0.25s ease-out;
}
/* Indeterminate progress sweep for the global delete indicator */
@keyframes indeterminate-sweep {
from {
transform: translateX(-100%);
}
to {
transform: translateX(400%);
}
}
.animate-indeterminate {
animation: indeterminate-sweep 1.1s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in {
animation-duration: 1ms;
}
/* Looping motion can't just be shortened the way a one-shot entrance can
that speeds it up rather than removing it. The delete indicator holds a
static busy state instead: the bar fills its track and the spinner stops,
leaving the toast text to carry the meaning. */
.animate-indeterminate {
animation: none;
width: 100%;
transform: none;
}
.animate-spin {
animation: none;
}
}
+22
View File
@@ -14,3 +14,25 @@ test('stats overlay mode paints an opaque full-viewport background', () => {
/body\.overlay-mode #root\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s,
);
});
test('reduced motion stops the looping delete indicator rather than speeding it up', () => {
const reducedMotion = /@media \(prefers-reduced-motion: reduce\)\s*\{(?<body>.*)\n\}/s.exec(css)
?.groups?.body;
assert.ok(reducedMotion, 'expected a prefers-reduced-motion block');
// Shortening an infinite animation makes it run faster, so the looping rules
// have to switch it off outright and hold a static state instead.
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation:\s*none;/s);
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*width:\s*100%;/s);
assert.match(reducedMotion, /\.animate-spin\s*\{[^}]*animation:\s*none;/s);
assert.doesNotMatch(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation-duration:/s);
assert.doesNotMatch(reducedMotion, /\.animate-spin\s*\{[^}]*animation-duration:/s);
});
test('the looping delete indicator keeps its animation for everyone else', () => {
const beforeMediaQuery = css.slice(0, css.indexOf('@media (prefers-reduced-motion'));
assert.match(
beforeMediaQuery,
/\.animate-indeterminate\s*\{\s*animation:\s*indeterminate-sweep/s,
);
});