mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
772635ab96
|
|||
|
675cb33519
|
|||
|
320db591ae
|
|||
| 7b0fbdf254 | |||
| 2fefc83e3f | |||
| dbdf578c68 | |||
| 441ecf3c04 | |||
|
a0dde4ee3e
|
|||
| fe4dacc1e7 | |||
| b08cd0db35 | |||
| bffb1c5982 | |||
| 5b8848518a | |||
|
176edd67f1
|
|||
| 4d65dec340 | |||
|
6607c333bc
|
|||
|
b2bbf1ae12
|
|||
| b204d4dd6e | |||
| 89ed675935 | |||
|
1995200e76
|
|||
|
e876e483d6
|
|||
| 6d2a72e13b | |||
| 0d7084c8aa | |||
|
95e0abc7b7
|
|||
|
d9155ceacb
|
@@ -1,5 +1,68 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
||||||
|
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
|
||||||
|
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
|
||||||
|
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
|
||||||
|
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
|
||||||
|
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
|
||||||
|
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
|
||||||
|
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||||
|
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
|
||||||
|
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
|
||||||
|
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
|
||||||
|
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
|
||||||
|
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
|
||||||
|
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
|
||||||
|
- Consolidated renderer modal state handling into a descriptor registry.
|
||||||
|
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
|
||||||
|
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
|
||||||
|
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
|
|||||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -90,6 +90,14 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<td><b>Jimaku</b></td>
|
<td><b>Jimaku</b></td>
|
||||||
<td>Search and download Japanese subtitles</td>
|
<td>Search and download Japanese subtitles</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>TsukiHime</b></td>
|
||||||
|
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>AniSkip</b></td>
|
||||||
|
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>alass / ffsubsync</b></td>
|
<td><b>alass / ffsubsync</b></td>
|
||||||
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||||
@@ -110,18 +118,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ----------- | ---------------------------------------- |
|
| -------------------- | ---------------- | -------------------------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on |
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
||||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
| yt-dlp | Optional | YouTube playback |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| fzf / rofi | Optional | Video picker in the launcher |
|
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
| guessit | Optional | Better anime title and episode detection |
|
| guessit | Optional | Better anime title and episode detection |
|
||||||
|
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
@@ -138,9 +147,23 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
|||||||
brew install mpv ffmpeg mecab mecab-ipadic
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
|
**Windows:**
|
||||||
|
|
||||||
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
|
```powershell
|
||||||
|
winget install shinchiro.mpv
|
||||||
|
winget install Gyan.FFmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
|
||||||
|
|
||||||
|
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scoop bucket add extras
|
||||||
|
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -166,6 +189,11 @@ paru -S subminer-bin
|
|||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
&& chmod +x ~/.local/bin/subminer
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.29.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -36,8 +36,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.0",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7",
|
||||||
|
"undici": "7.29.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
@@ -266,7 +268,7 @@
|
|||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
@@ -714,7 +716,7 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
@@ -754,8 +756,6 @@
|
|||||||
|
|
||||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
|
||||||
|
|
||||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
@@ -808,8 +808,6 @@
|
|||||||
|
|
||||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
|
||||||
|
|
||||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||||
|
|
||||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
|
|
||||||
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
|
|
||||||
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: added
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Added an in-app changelog modal, opened from the tray ("View Changelog") or the "What's New" button on the update-available notification, which now stays on screen so "Update" is still reachable after reading the notes. It renders inside the player bounds when a video is playing and in its own window otherwise, the same as the help modal.
|
||||||
|
- The changelog is fetched from the newest published release, so release notes for versions newer than the installed build are visible; a failed download falls back to the changelog bundled with the install and says so in the modal.
|
||||||
|
- Versions are foldable: the current `0.x` line is expanded and older lines are folded, matching the docs-site changelog. A badge marks the installed version and newer versions are tagged "New".
|
||||||
|
- Keyboard: `J`/`K` or arrows move between versions, `Enter` folds/unfolds, `R` refetches, `Esc` closes.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: shortcuts
|
|
||||||
|
|
||||||
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: app
|
|
||||||
|
|
||||||
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: character dictionary
|
|
||||||
|
|
||||||
- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Stats reported 0 known words for every session after the known-word cache gained maturity tiers. The stats server carried its own copy of the cache parser that only recognized versions up to 3, so the new v4 file was read as "no cache" rather than as a format it should understand.
|
|
||||||
- The cache format, its parser, and the derived known-word set now live in one module that both the cache manager and the stats server read, and the version dispatch ends in an exhaustive check so a future format bump fails the build instead of silently reporting zero. A cache that exists but does not parse now logs a warning rather than passing for an empty one.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Added a golden-file regression corpus for the tokenizer/annotation pipeline: recorded Yomitan backend responses and MeCab tokens replay through the real tokenizeSubtitle pipeline in bun tests without Electron or dictionaries.
|
|
||||||
- Added `record-tokenizer-fixture:electron` script to capture new fixtures from a live Yomitan/MeCab session, with flags for known words, JLPT levels, and annotation toggles.
|
|
||||||
- Seeded eleven fixtures covering the #147–#156 regression classes (grammar-helper suppression, lexical くれる, kanji non-independent nouns, N+1 targeting, reading collisions, unparsed runs, ordinal/honorific prefixes).
|
|
||||||
- Added `compare-yomitan-api:electron` script that diffs SubMiner tokenization against a stock Yomitan instance via the yomitan-api bridge (segmentation, readings, headword forms).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
|
||||||
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Consolidated renderer modal state handling into a descriptor registry.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Consolidated pull request, stable release, and prerelease quality checks in one reusable workflow, with Lua mpv plugin tests and blocking high-severity dependency audits running in every gate.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
|
|
||||||
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Kept the stats page and active video player responsive during deletes, and batched concurrent session, episode, and library deletes into one transaction and summary rebuild.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Heavily typeset ASS scripts (karaoke OP/ED, sign work) no longer fill the subtitle sidebar with garbage. Vector drawing runs (`\p1` … `\p0`) are no longer shown as subtitle text (e.g. `m 20 0 b 10 0 0 10 0 20 …`), and duplicate events in a parsed subtitle file collapse into one cue: identical text over an identical span (layered "shadow" copies), and per-frame animation bursts. An ASS burst has to prove itself with authoring evidence — a temporal tag (`\t`, `\move`, karaoke timing), an animated `Effect` column, or override values that change from event to event — plus one shared style and actor, so three rapid `えっ` reactions from three characters, or a sign repeated with the same static `\clip`, stay separate. SRT and VTT carry no such metadata, so there the run has to be at least five contiguous events all shorter than 0.1s, which is where ASS-to-SRT conversion leaves karaoke frames.
|
||||||
|
- Subtitle text is now decoded from ASS exactly once, where it enters the app, and matches how mpv renders the same line (including `\N`, `\n`, `\h`, unclosed `{`, and the fact that `\{` is not an escape). Renderer, timing tracker, tokenizer and the tokenization cache take that decoded text as-is instead of each re-deriving it, so one authored line can no longer produce two different cache keys, and a cue that normalizes to nothing is no longer stored as subtitle text or cached under an empty key.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
type: changed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Subtitle tokenization no longer runs a duplicate full `parseText` pass per line: the termsFind scanner walk is now the only tokenizer and emits its own hoverable filler runs for unmatched text (parseText is kept only as an error fallback). This roughly halves the dictionary work per line.
|
||||||
|
- The Yomitan scanning helpers are now installed once per parser window (`__subminerYomitanScan`) instead of re-shipping and re-parsing a ~500-line script for every subtitle line; each line only evaluates a tiny call.
|
||||||
|
- termsFind lookups are cached across subtitle lines in a window-persistent LRU keyed by substring, so repeated particles and verb forms stop costing backend round trips. The cache invalidates on dictionary/settings changes and window reloads.
|
||||||
|
- The scanner walk now skips lookups at punctuation and whitespace positions (latin letters and digits still look up, e.g. Tシャツ). The shrinking-window retry ladder keeps following the consumed lengths the backend reports, and only blind guesses (windows the backend consumed whole, which tell it nothing) are capped at four per position. A line that hits that cap escalates to a single `parseText` for the whole line, so a hard line still resolves to dictionary tokens instead of an unparsed run, without letting the ladder run to one lookup per window length.
|
||||||
|
- Tokenizer runtime dependencies are built once instead of per line, fixing a JLPT lookup cache that never hit (it was keyed on a per-call closure identity and leaked a Map per line) and a `which mecab` availability check that re-ran synchronously on every line when MeCab is absent.
|
||||||
|
- Subtitle changes no longer restart the prefetch run per line (which discarded in-flight tokenization work); prefetch now only pauses for the live line and restarts on real seeks, cache invalidation, or option changes. Prefetch also stays paused for the whole time the subtitle processing controller is working on the line, including the provisional raw emit that precedes tokenization, so it never competes with the on-screen line for the parser window. The pause is released when the controller reports it has settled, which also covers the lines that finish without an emit (a suppressed duplicate or a failed tokenization) and used to leave prefetching paused indefinitely.
|
||||||
|
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||||
|
- Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens.
|
||||||
|
- Subtitle prefetching no longer stays paused for the rest of a cue when the same subtitle text is reported twice and there is nothing to tokenize. This covers the startup and overlay priming paths as well as ordinary subtitle changes.
|
||||||
|
- Character name and image lookups are now refreshed centrally whenever a character dictionary sync changes its content, so a newly added name can no longer be skipped by a stale candidate list.
|
||||||
|
- A subtitle that was on screen when its annotations were invalidated (by mining a card, for example) is now re-annotated instead of staying plain for the rest of the line.
|
||||||
|
- Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name.
|
||||||
|
- The cross-line termsFind cache is now bounded by the number of retained dictionary entries as well as by key count, so a run of lookups that each carry hundreds of entries with full glossaries cannot grow the parser window's memory without limit. The budget is re-checked when a lookup resolves, so a single oversized response is dropped rather than parked in the cache and reused.
|
||||||
|
- The unnamed-mob disambiguator filter (Girl A / Girl B) now only drops a single letter or digit split off a name, instead of every one-character term: a name that is genuinely one character keeps its terms whatever the script (𠮷, あ, 별 김, ア・ベ). The character dictionary and the scanner's name pre-pass also share one Han code-point table now, so a name the dictionary accepts is a name the scanner will look for.
|
||||||
|
- A character name written in halfwidth katakana takes part in the greedy name pre-pass again, so a longer generic word can no longer swallow the start of it, and it now carries a reading (it used to come out blank, which disables known-word matching and frequency lookups for the token). Voiced halfwidth kana compose properly, so ガク reads ガク rather than ガク, and kana normalization folds halfwidth throughout so those tokens compare equal to the same word written fullwidth. Because the fold makes halfwidth text indexable, the character-name prefilter now judges halfwidth spellings like any other, and a position only bypasses it when an unfoldable voiced mark sits inside the lookup window. That covers a name that starts on a kanji and turns halfwidth later (山ガク), and one stretched out with emphatic characters in between (山ーーーーーーガク).
|
||||||
|
- Dictionary-entry classification (source dictionaries, character-dictionary media ids) is memoized per entry object for as long as the entry is cached, instead of being recomputed for every headword comparison and every retry window.
|
||||||
|
- Autoplay priming no longer broadcasts the plain subtitle twice: it tells the processing controller the line has already been painted, so the controller goes straight to the annotated payload.
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: sync
|
|
||||||
|
|
||||||
- Added cross-machine immersion sync for stats and watch history over SSH, available as a window (**Sync Stats & History** in the tray menu, or `subminer sync --ui`) and as a command (`subminer sync <host>`, with `--push` / `--pull` for one-way insert-only transfers). The window keeps saved devices with per-host direction, one-click sync with live stage-by-stage progress and separate merge summaries for each machine, connection testing for first-time setup, cancellable runs while the app/stats server/playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled sync in the background on a configurable interval, including during playback, with results reported as overlay notifications; hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and appear in the window automatically.
|
|
||||||
- Merges are an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted: each side snapshots its database (`VACUUM INTO`) from a consistent WAL point, snapshots are exchanged with `scp`, and each machine merges the other's data transactionally. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved), unfinished sessions are excluded until a later sync sees them finalized, and remote-only historical rollups are copied only when they do not conflict with retained local session history. Sync aborts on stats schema version mismatches and refuses to run while the stats daemon or a live mpv session is active (`--force` overrides).
|
|
||||||
- The sync engine runs only inside the app: the sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
|
|
||||||
- Windows remotes are supported: sync detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp` / `--remove-temp`) instead of `mktemp` / `rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote, found in its default Windows install location automatically.
|
|
||||||
- Added supporting flags: `subminer sync <host> --check` tests the SSH connection and remote launcher availability without syncing, `subminer sync --snapshot <file>` and `--merge <file>` expose the underlying steps for manual transfers, and `subminer sync --json` emits machine-readable NDJSON progress (the protocol the sync window consumes).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
|
|
||||||
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
|
|
||||||
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
|
|
||||||
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: playback
|
||||||
|
|
||||||
|
- XWayland/X11 mode (`--backend=x11`, or the automatic fallback on non-Hyprland/Sway Wayland sessions) no longer forces mpv onto `--vo=gpu --gpu-api=opengl`. It now only pins the window context (`--gpu-context=x11vk,x11egl,x11`), so a `vo=gpu-next` config keeps its renderer, API, and user shaders. Forcing the legacy OpenGL renderer crashed mpv on the first fullscreen toggle for anyone using a gpu-next user shader that emits a 4-component LUMA hook (ArtCNN and friends), which asserts in mpv's old renderer (`copy_image: *offset + count < sizeof(dst)`) as soon as the shader's upscale-only condition turns on.
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
|||||||
The update flow:
|
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.
|
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.
|
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).
|
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.
|
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
|
## 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.
|
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.
|
||||||
@@ -93,15 +95,15 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `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: `""`) |
|
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||||
|
|
||||||
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
|||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|
||||||
|
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
||||||
|
|
||||||
## Field Grouping (Kiku)
|
## Field Grouping (Kiku)
|
||||||
|
|
||||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||||
@@ -313,11 +328,11 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | ---------------------------------------- |
|
| -------- | --------------------------------------------- |
|
||||||
| Sentence | Both cards' sentences kept as grouped entries |
|
| Sentence | Both cards' sentences kept as grouped entries |
|
||||||
| Audio | Both cards' `[sound:...]` entries kept |
|
| Audio | Both cards' `[sound:...]` entries kept |
|
||||||
| Image | Both cards' images kept |
|
| Image | Both cards' images kept |
|
||||||
|
|
||||||
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ src/
|
|||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||||
# character dictionary, playlist browser, subtitle sidebar,
|
# changelog, character dictionary, playlist browser, subtitle
|
||||||
# YouTube track picker, controller config/debug/select)
|
# sidebar, YouTube track picker, controller config/debug/select)
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
settings/ # Settings window UI (model, controls, markup)
|
settings/ # Settings window UI (model, controls, markup)
|
||||||
types/ # Domain type modules (anki, config, integrations, ...)
|
types/ # Domain type modules (anki, config, integrations, ...)
|
||||||
|
|||||||
+70
-2
@@ -1,6 +1,74 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
||||||
|
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
|
||||||
|
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
|
||||||
|
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
|
||||||
|
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
|
||||||
|
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
|
||||||
|
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
|
||||||
|
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||||
|
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
|
||||||
|
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
|
||||||
|
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
|
||||||
|
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
|
||||||
|
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
|
||||||
|
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
|
||||||
|
- Consolidated renderer modal state handling into a descriptor registry.
|
||||||
|
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
|
||||||
|
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
|
||||||
|
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Previous Versions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.18.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.18.0 (2026-07-10)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||||
@@ -31,7 +99,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Previous Versions
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.17.x</summary>
|
<summary>v0.17.x</summary>
|
||||||
|
|||||||
@@ -223,7 +223,9 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
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
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
|||||||
+74
-56
@@ -398,30 +398,30 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||||
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
||||||
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
||||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
|
|
||||||
Subtitle CSS custom properties:
|
Subtitle CSS custom properties:
|
||||||
|
|
||||||
@@ -555,11 +555,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
||||||
|
|
||||||
@@ -1043,7 +1043,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": true
|
"deleteDuplicateInAuto": true
|
||||||
|
},
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1077,6 +1080,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
||||||
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
||||||
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||||
|
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
|
||||||
|
|
||||||
|
### Word Card Type
|
||||||
|
|
||||||
|
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||||
|
|
||||||
|
| Value | Flag set |
|
||||||
|
| ----------------------------- | ----------------------- |
|
||||||
|
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
||||||
|
| `click` | `IsClickCard` |
|
||||||
|
| `sentence` | `IsSentenceCard` |
|
||||||
|
| `audio` | `IsAudioCard` |
|
||||||
|
| `none` | none; flags left as-is |
|
||||||
|
|
||||||
|
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -1167,10 +1185,10 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||||
|
|
||||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
||||||
|
|
||||||
@@ -1178,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
|
|||||||
|
|
||||||
### Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||||
|
|
||||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
|
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
|
||||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1228,17 +1246,17 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||||
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
||||||
|
|
||||||
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
||||||
|
|
||||||
@@ -1539,18 +1557,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
||||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||||
|
|
||||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Trends
|
#### Trends
|
||||||
|
|||||||
@@ -183,12 +183,8 @@ Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -ve
|
|||||||
|
|
||||||
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
||||||
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
||||||
3. Press `Win`, type **Edit the system environment variables**, and open it.
|
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
|
||||||
4. Click **Environment Variables…**
|
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
|
||||||
5. Under **User variables**, select **Path** and click **Edit…**
|
|
||||||
6. Click **New** and paste the folder containing `mpv.exe`. Click **New** again and paste the folder containing `ffmpeg.exe`.
|
|
||||||
7. Click **OK** on all three dialogs.
|
|
||||||
8. Close and reopen your terminal (PATH changes only apply to newly opened windows), then check:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mpv --version
|
mpv --version
|
||||||
@@ -197,8 +193,10 @@ ffmpeg -version
|
|||||||
|
|
||||||
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
||||||
|
|
||||||
::: tip Don't want to touch PATH at all?
|
::: tip mpv can skip PATH, ffmpeg cannot
|
||||||
mpv is the only hard requirement, and you can point SubMiner straight at it instead: leave `PATH` alone and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup. ffmpeg still needs to be on `PATH` for card audio and screenshots.
|
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
|
||||||
|
|
||||||
|
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
|||||||
|
|
||||||
1. Open the subsync modal from the overlay.
|
1. Open the subsync modal from the overlay.
|
||||||
2. Select the sync engine (alass or ffsubsync).
|
2. Select the sync engine (alass or ffsubsync).
|
||||||
3. For alass, select a reference subtitle track from the video.
|
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
|
||||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
|
||||||
|
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
|
||||||
|
|
||||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
|
||||||
|
|
||||||
|
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
||||||
|
|
||||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
|
|||||||
|
|
||||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||||
|
|
||||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
||||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||||
- Try running the sync tool manually to see detailed error output.
|
- Try running the sync tool manually to see detailed error output.
|
||||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||||
@@ -406,7 +406,7 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
|
|||||||
SubMiner handles this automatically:
|
SubMiner handles this automatically:
|
||||||
|
|
||||||
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
|
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
|
||||||
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
|
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
|
||||||
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
||||||
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
||||||
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
||||||
@@ -420,7 +420,7 @@ Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner use
|
|||||||
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
|
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
|
||||||
|
|
||||||
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
|
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
|
||||||
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
|
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
|
||||||
|
|
||||||
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
|
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -145,11 +145,13 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
|
|||||||
|
|
||||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||||
|
|
||||||
|
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
||||||
|
|
||||||
### Logging and App Mode
|
### Logging and App Mode
|
||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--log-level` controls logger verbosity.
|
||||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||||
@@ -368,6 +370,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
|
|||||||
|
|
||||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||||
|
|
||||||
|
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
|
||||||
|
|
||||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||||
|
|
||||||
### Drag-and-Drop
|
### Drag-and-Drop
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
|||||||
- **Client auth:** none
|
- **Client auth:** none
|
||||||
- **Reconnects:** client-managed
|
- **Reconnects:** client-managed
|
||||||
|
|
||||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
|
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
|
|||||||
|
|
||||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||||
|
|
||||||
|
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
|||||||
|
|
||||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||||
|
|
||||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
|
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||||
|
|||||||
@@ -64,18 +64,23 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
|||||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||||
|
|
||||||
**Parsed cue structure:**
|
**Parsed cue structure:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface SubtitleCue {
|
interface SubtitleCue {
|
||||||
startTime: number; // seconds
|
startTime: number; // seconds
|
||||||
endTime: number; // seconds
|
endTime: number; // seconds
|
||||||
text: string; // raw subtitle text
|
text: string; // plain text, decoded from the source format
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Supported formats:**
|
**Supported formats:**
|
||||||
|
|
||||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
|
||||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
|
||||||
|
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||||
|
|
||||||
|
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||||
|
|
||||||
#### Prefetch Service Lifecycle
|
#### Prefetch Service Lifecycle
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
|||||||
### Dependency Analysis
|
### Dependency Analysis
|
||||||
|
|
||||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||||
|
|
||||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||||
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
|||||||
|
|
||||||
// Single pass: known word + frequency filtering + JLPT computed together
|
// Single pass: known word + frequency filtering + JLPT computed together
|
||||||
const annotated = tokens.map((token) => {
|
const annotated = tokens.map((token) => {
|
||||||
const isKnown = nPlusOneEnabled
|
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||||
? token.isKnown || computeIsKnown(token, deps)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||||
const frequencyRank = frequencyEnabled
|
const frequencyRank = frequencyEnabled
|
||||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const jlptLevel = jlptEnabled
|
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||||
? computeJlptLevel(token, deps.getJlptLevel)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||||
});
|
});
|
||||||
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
|||||||
### Current Behavior
|
### Current Behavior
|
||||||
|
|
||||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||||
|
|
||||||
1. Clears DOM with `innerHTML = ''`
|
1. Clears DOM with `innerHTML = ''`
|
||||||
2. Creates a `DocumentFragment`
|
2. Creates a `DocumentFragment`
|
||||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||||
@@ -256,27 +259,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
|||||||
|
|
||||||
## Combined Impact Summary
|
## Combined Impact Summary
|
||||||
|
|
||||||
| Scenario | Before | After | Improvement |
|
| Scenario | Before | After | Improvement |
|
||||||
|----------|--------|-------|-------------|
|
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files Summary
|
## Files Summary
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
- `src/core/services/subtitle-prefetch.ts`
|
- `src/core/services/subtitle-prefetch.ts`
|
||||||
- `src/core/services/subtitle-cue-parser.ts`
|
- `src/core/services/subtitle-cue-parser.ts`
|
||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
|
|
||||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||||
- `src/main.ts` (wire up prefetch service)
|
- `src/main.ts` (wire up prefetch service)
|
||||||
|
|
||||||
### Test Files
|
### Test Files
|
||||||
|
|
||||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||||
- Updated tests for annotation stage (same behavior, new implementation)
|
- Updated tests for annotation stage (same behavior, new implementation)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||||
|
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; expensive deletion and summary rebuilds run in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and lifetime rebuild.
|
||||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-06-14
|
Last verified: 2026-08-04
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -47,18 +47,43 @@ subtitles do not draw.
|
|||||||
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||||
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||||
payload.
|
synchronously, then replaces it with the tokenized payload when ready.
|
||||||
|
|
||||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
|
||||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
||||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
||||||
prefetch work and re-centers prefetch around the live playback time.
|
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
||||||
|
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
||||||
|
|
||||||
|
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
||||||
|
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
||||||
|
overlay receives one plain payload followed by the annotated one.
|
||||||
|
|
||||||
|
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
|
||||||
|
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
|
||||||
|
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
|
||||||
|
a failed tokenization). Both controller methods return whether processing is now pending, and the
|
||||||
|
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
|
||||||
|
coming and prefetching would otherwise idle for the rest of the cue.
|
||||||
|
|
||||||
|
## Live Cue Delivery
|
||||||
|
|
||||||
|
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||||
|
live work does not contend for Yomitan state.
|
||||||
|
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||||
|
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||||
|
replace the current cue.
|
||||||
|
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||||
|
or character images, but cue visibility does not wait for that work.
|
||||||
|
|
||||||
## Emitted State
|
## Emitted State
|
||||||
|
|
||||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||||
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
|
||||||
|
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||||
|
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||||
|
when text and cue timing match.
|
||||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway a
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend resolves to x11', () => {
|
test('buildMpvBackendArgs pins the X11 window context when backend resolves to x11', () => {
|
||||||
withPlatform('linux', () => {
|
withPlatform('linux', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
|
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
|
||||||
@@ -230,12 +230,12 @@ test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend res
|
|||||||
WAYLAND_DISPLAY: 'wayland-0',
|
WAYLAND_DISPLAY: 'wayland-0',
|
||||||
XDG_SESSION_TYPE: 'wayland',
|
XDG_SESSION_TYPE: 'wayland',
|
||||||
}),
|
}),
|
||||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
['--gpu-context=x11vk,x11egl,x11'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Wayland auto fallback', () => {
|
test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayland auto fallback', () => {
|
||||||
withPlatform('linux', () => {
|
withPlatform('linux', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
||||||
@@ -245,7 +245,7 @@ test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Way
|
|||||||
XDG_CURRENT_DESKTOP: 'KDE',
|
XDG_CURRENT_DESKTOP: 'KDE',
|
||||||
XDG_SESSION_DESKTOP: 'plasma',
|
XDG_SESSION_DESKTOP: 'plasma',
|
||||||
}),
|
}),
|
||||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
['--gpu-context=x11vk,x11egl,x11'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -292,9 +292,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
|||||||
'--secondary-sub-visibility=no',
|
'--secondary-sub-visibility=no',
|
||||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--vo=gpu',
|
'--gpu-context=x11vk,x11egl,x11',
|
||||||
'--gpu-api=opengl',
|
|
||||||
'--gpu-context=x11egl,x11',
|
|
||||||
'--window-maximized=yes',
|
'--window-maximized=yes',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
line_id INTEGER NOT NULL,
|
line_id INTEGER NOT NULL,
|
||||||
word_id INTEGER NOT NULL,
|
word_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, word_id),
|
PRIMARY KEY(line_id, word_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(word_id) REFERENCES imm_words(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,
|
line_id INTEGER NOT NULL,
|
||||||
kanji_id INTEGER NOT NULL,
|
kanji_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, kanji_id),
|
PRIMARY KEY(line_id, kanji_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(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_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_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_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_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 ON imm_kanji_line_occurrences(kanji_id, 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_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_anilist_id ON imm_media_art(anilist_id);
|
||||||
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
||||||
|
|||||||
+10
-4
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.0-beta.4",
|
"version": "0.19.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -84,8 +84,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.0",
|
||||||
@@ -93,7 +94,8 @@
|
|||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7"
|
"tmp": "0.2.7",
|
||||||
|
"undici": "7.29.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anki",
|
"anki",
|
||||||
@@ -125,7 +127,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.6.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.29.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -258,6 +260,10 @@
|
|||||||
{
|
{
|
||||||
"from": "dist/launcher/subminer",
|
"from": "dist/launcher/subminer",
|
||||||
"to": "launcher/subminer"
|
"to": "launcher/subminer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "CHANGELOG.md",
|
||||||
|
"to": "CHANGELOG.md"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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
|
### Changed
|
||||||
|
|
||||||
- **Clipboard-Video Shortcut**
|
- **Clipboard-Video Shortcut**
|
||||||
@@ -34,8 +39,9 @@
|
|||||||
- **Word Highlighting Accuracy**
|
- **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.
|
- 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**
|
- **AniList Season Resolution**
|
||||||
- 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.
|
- 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**
|
- **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.
|
- 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.
|
- 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.
|
- 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 Prompt Spacing**
|
||||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
- 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
|
- 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
|
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
|||||||
+7
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
|
type WordCardKind,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
withUpdateProgress,
|
withUpdateProgress,
|
||||||
UiFeedbackState,
|
UiFeedbackState,
|
||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
|
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
@@ -83,8 +86,6 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -840,6 +841,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
} {
|
} {
|
||||||
const lapis = this.getLapisConfig();
|
const lapis = this.getLapisConfig();
|
||||||
const kiku = this.getKikuConfig();
|
const kiku = this.getKikuConfig();
|
||||||
@@ -852,6 +854,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: kiku.enabled,
|
kikuEnabled: kiku.enabled,
|
||||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||||
|
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
|
|||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||||
|
this.resolveFieldName(availableFieldNames, preferredName),
|
||||||
if (cardKind === 'word-and-sentence') {
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (!wordAndSentenceFlag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = 'x';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== sentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedAudioFlags = Array.from(
|
|
||||||
new Set(
|
|
||||||
audioFlagNames
|
|
||||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
|
||||||
.filter((name): name is string => Boolean(name)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
|
||||||
if (audioFlagName) {
|
|
||||||
updatedFields[audioFlagName] = 'x';
|
|
||||||
}
|
|
||||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
|
||||||
updatedFields[extraAudioFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showNotification(
|
private async showNotification(
|
||||||
|
|||||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
|||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
if (cardKind !== 'word-and-sentence') return;
|
applyCardKindFlagFields(
|
||||||
|
updatedFields,
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
cardKind,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
kikuFieldGrouping: 'disabled',
|
kikuFieldGrouping: 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: false,
|
kikuDeleteDuplicateInAuto: false,
|
||||||
}),
|
}),
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveAudioStreamIndexForMediaGeneration,
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
deck: string,
|
deck: string,
|
||||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
|
function resolverFor(availableFieldNames: string[]) {
|
||||||
|
return (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||||
|
|
||||||
|
test('flags the requested card kind and clears the others', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches flag fields case-insensitively', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'click',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'audio',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not blank the target flag it just set', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||||
|
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||||
|
*/
|
||||||
|
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||||
|
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||||
|
click: 'IsClickCard',
|
||||||
|
sentence: 'IsSentenceCard',
|
||||||
|
audio: 'IsAudioCard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||||
|
'word-and-sentence',
|
||||||
|
'click',
|
||||||
|
'sentence',
|
||||||
|
'audio',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||||
|
* when the note type actually carries the matching flag field, so plain note types keep
|
||||||
|
* their fields untouched.
|
||||||
|
*/
|
||||||
|
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||||
|
|
||||||
|
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||||
|
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||||
|
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||||
|
* never ends up claiming to be two kinds of card at once.
|
||||||
|
*/
|
||||||
|
export function applyCardKindFlagFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
cardKind: CardKind,
|
||||||
|
resolveFieldName: (preferredName: string) => string | null,
|
||||||
|
): void {
|
||||||
|
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||||
|
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetFlag) {
|
||||||
|
updatedFields[targetFlag] = 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||||
|
if (kind === cardKind) continue;
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== targetFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||||
|
|
||||||
|
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
Expression: '単語',
|
||||||
|
Sentence: '',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsClickCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors the configured word card kind', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the word card kind is "none"', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'none',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the default kind for an unrecognized setting', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'bogus' as never,
|
||||||
|
}),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||||
|
const note: NoteFieldValueInfo = {
|
||||||
|
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||||
|
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
flagField,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'sentence',
|
||||||
|
}),
|
||||||
|
'sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides a differently flagged word card', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
CARD_KIND_FLAG_FIELDS,
|
||||||
|
DEFAULT_WORD_CARD_KIND,
|
||||||
|
resolveWordCardKindSetting,
|
||||||
|
} from './card-kinds';
|
||||||
|
|
||||||
|
const log = createLogger('anki').child('integration.note-fields');
|
||||||
|
|
||||||
export interface NoteFieldValueInfo {
|
export interface NoteFieldValueInfo {
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
|||||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldMarkWordAndSentenceCard(
|
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||||
noteInfo: NoteFieldValueInfo,
|
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
|
||||||
): boolean {
|
|
||||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
const warnedMissingFlagFields = new Set<CardKind>();
|
||||||
if (wordAndSentenceValue === null) {
|
|
||||||
return false;
|
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||||
|
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||||
|
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (wordAndSentenceValue.trim().length > 0) {
|
warnedMissingFlagFields.add(wordCardKind);
|
||||||
return true;
|
log.warn(
|
||||||
}
|
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||||
return (
|
|
||||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||||
|
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||||
|
*/
|
||||||
|
export function resolveWordCardKind(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: {
|
||||||
|
lapisEnabled: boolean;
|
||||||
|
kikuEnabled: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
},
|
||||||
|
): CardKind | null {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||||
|
if (wordCardKind === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||||
|
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||||
|
if (flagValue === null) {
|
||||||
|
// Note type has no flag field for the configured kind.
|
||||||
|
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (flagValue.trim().length > 0) {
|
||||||
|
return wordCardKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||||
|
(fieldName) =>
|
||||||
|
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||||
|
);
|
||||||
|
return alreadyExplicitCard ? null : wordCardKind;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import {
|
|||||||
type NoteUpdateWorkflowNoteInfo,
|
type NoteUpdateWorkflowNoteInfo,
|
||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import type { CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
assert.equal(cardKind, 'word-and-sentence');
|
applyCardKindFlagFields(
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
updatedFields,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
cardKind,
|
||||||
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'click',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: 'x' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'none',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
lapisEnabled: boolean;
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
) => void;
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
|||||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||||
...(config.isKiku ?? {}),
|
...(config.isKiku ?? {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||||
|
...(config.lapisKiku ?? {}),
|
||||||
|
},
|
||||||
} as AnkiConnectConfig;
|
} as AnkiConnectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
|
|||||||
patch.isKiku !== undefined
|
patch.isKiku !== undefined
|
||||||
? { ...this.config.isKiku, ...patch.isKiku }
|
? { ...this.config.isKiku, ...patch.isKiku }
|
||||||
: this.config.isKiku,
|
: this.config.isKiku,
|
||||||
|
lapisKiku:
|
||||||
|
patch.lapisKiku !== undefined
|
||||||
|
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||||
|
: this.config.lapisKiku,
|
||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
|
|||||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": { "wordCardKind": "click" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||||
|
assert.equal(service.getWarnings().length, 0);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
fieldGrouping: 'disabled',
|
fieldGrouping: 'disabled',
|
||||||
deleteDuplicateInAuto: true,
|
deleteDuplicateInAuto: true,
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'word-and-sentence',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
jimaku: {
|
jimaku: {
|
||||||
apiBaseUrl: 'https://jimaku.cc',
|
apiBaseUrl: 'https://jimaku.cc',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
|
||||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPE_VALUES,
|
NOTIFICATION_TYPE_VALUES,
|
||||||
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||||
description: 'Note type name used by Lapis sentence cards.',
|
description: 'Note type name used by Lapis sentence cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: WORD_CARD_KINDS,
|
||||||
|
enumLabels: {
|
||||||
|
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||||
|
click: 'Click card (IsClickCard)',
|
||||||
|
sentence: 'Sentence card (IsSentenceCard)',
|
||||||
|
audio: 'Audio card (IsAudioCard)',
|
||||||
|
none: 'Leave card type flags untouched',
|
||||||
|
},
|
||||||
|
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
description:
|
||||||
|
'Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.metadata.pattern',
|
path: 'ankiConnect.metadata.pattern',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||||
|
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||||
applyAnkiKikuResolution(context);
|
applyAnkiKikuResolution(context);
|
||||||
|
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
|||||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...context.resolved.ankiConnect.lapisKiku,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyAnkiLapisKikuResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!isObject(ankiConnect.lapisKiku)) {
|
||||||
|
if (ankiConnect.lapisKiku !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku',
|
||||||
|
ankiConnect.lapisKiku,
|
||||||
|
context.resolved.ankiConnect.lapisKiku,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
if (wordCardKind === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWordCardKind(wordCardKind)) {
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
wordCardKind,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
}
|
||||||
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||||
'ankiConnect.isLapis.sentenceCardModel':
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind':
|
||||||
|
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
|
||||||
'subtitleStyle.css':
|
'subtitleStyle.css':
|
||||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleStyle.secondary.css':
|
'subtitleStyle.secondary.css':
|
||||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.media.')) {
|
if (path.startsWith('ankiConnect.media.')) {
|
||||||
return { category: 'mining-anki', section: 'Media Capture' };
|
return { category: 'mining-anki', section: 'Media Capture' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
if (
|
||||||
|
path.startsWith('ankiConnect.isKiku.') ||
|
||||||
|
path.startsWith('ankiConnect.isLapis.') ||
|
||||||
|
path.startsWith('ankiConnect.lapisKiku.')
|
||||||
|
) {
|
||||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.ai.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||||
path === 'mpv.aniskipEnabled' ||
|
path === 'mpv.aniskipEnabled' ||
|
||||||
path === 'mpv.aniskipButtonKey' ||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
|
|||||||
@@ -1100,12 +1100,56 @@ describe('stats server API routes', () => {
|
|||||||
assert.deepEqual(assignments, [{ animeId: 1, body }]);
|
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 app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anime/1/cover');
|
const res = await app.request('/api/stats/anime/1/cover');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.headers.get('content-type'), 'image/jpeg');
|
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 () => {
|
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
|
||||||
@@ -2410,6 +2454,80 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.IsClickCard, 'x');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
@@ -2873,6 +2991,39 @@ Aligned English subtitle
|
|||||||
assert.equal(deleteCalls, 0);
|
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 () => {
|
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
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');
|
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;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
season?: number | null;
|
season?: number | null;
|
||||||
|
/** Pinned AniList media id from a manual override, when one applied at enqueue time. */
|
||||||
|
mediaId?: number | null;
|
||||||
episode: number;
|
episode: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
@@ -29,13 +31,31 @@ export interface AnilistRetryQueueSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnilistUpdateQueue {
|
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;
|
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||||
markSuccess: (key: string) => void;
|
markSuccess: (key: string) => void;
|
||||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||||
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
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 {
|
function clampBackoffMs(attemptCount: number): number {
|
||||||
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
||||||
return Math.min(MAX_BACKOFF_MS, computed);
|
return Math.min(MAX_BACKOFF_MS, computed);
|
||||||
@@ -82,6 +102,7 @@ export function createAnilistUpdateQueue(
|
|||||||
typeof item.createdAt === 'number' &&
|
typeof item.createdAt === 'number' &&
|
||||||
typeof item.attemptCount === 'number' &&
|
typeof item.attemptCount === 'number' &&
|
||||||
typeof item.nextAttemptAt === 'number' &&
|
typeof item.nextAttemptAt === 'number' &&
|
||||||
|
isValidPersistedMediaId(item.mediaId) &&
|
||||||
(typeof item.lastError === 'string' || item.lastError === null),
|
(typeof item.lastError === 'string' || item.lastError === null),
|
||||||
)
|
)
|
||||||
.slice(0, MAX_ITEMS);
|
.slice(0, MAX_ITEMS);
|
||||||
@@ -96,6 +117,7 @@ export function createAnilistUpdateQueue(
|
|||||||
typeof item.createdAt === 'number' &&
|
typeof item.createdAt === 'number' &&
|
||||||
typeof item.attemptCount === 'number' &&
|
typeof item.attemptCount === 'number' &&
|
||||||
typeof item.nextAttemptAt === 'number' &&
|
typeof item.nextAttemptAt === 'number' &&
|
||||||
|
isValidPersistedMediaId(item.mediaId) &&
|
||||||
(typeof item.lastError === 'string' || item.lastError === null),
|
(typeof item.lastError === 'string' || item.lastError === null),
|
||||||
)
|
)
|
||||||
.slice(0, MAX_ITEMS);
|
.slice(0, MAX_ITEMS);
|
||||||
@@ -107,7 +129,13 @@ export function createAnilistUpdateQueue(
|
|||||||
load();
|
load();
|
||||||
|
|
||||||
return {
|
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 =
|
const existing =
|
||||||
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -120,6 +148,7 @@ export function createAnilistUpdateQueue(
|
|||||||
key,
|
key,
|
||||||
title,
|
title,
|
||||||
season,
|
season,
|
||||||
|
mediaId,
|
||||||
episode,
|
episode,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
attemptCount: 0,
|
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 originalFetch = globalThis.fetch;
|
||||||
const searchTerms: string[] = [];
|
const searchTerms: string[] = [];
|
||||||
let call = 0;
|
let call = 0;
|
||||||
@@ -389,21 +389,45 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
|
|||||||
data: {
|
data: {
|
||||||
Page: {
|
Page: {
|
||||||
media: [
|
media: [
|
||||||
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
|
{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } },
|
||||||
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
|
{ id: 202, episodes: 12, format: 'TV', title: { english: 'Demo Show Sequel' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (call === 2) {
|
if (call === 2) {
|
||||||
assert.equal(body.variables?.mediaId, 202);
|
assert.equal(body.variables?.id, 101);
|
||||||
return createJsonResponse({
|
return createJsonResponse({
|
||||||
data: {
|
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({
|
return createJsonResponse({
|
||||||
data: {
|
data: {
|
||||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
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, {
|
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
||||||
season: 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.status, 'error');
|
||||||
assert.equal(result.retryable, false);
|
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);
|
assert.equal(call, 2);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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 () => {
|
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let call = 0;
|
let call = 0;
|
||||||
@@ -479,3 +605,33 @@ test('updateAnilistPostWatchProgress returns error when search fails', async ()
|
|||||||
globalThis.fetch = originalFetch;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as path from 'path';
|
|||||||
|
|
||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
import type { AnilistRateLimiter } from './rate-limiter';
|
import type { AnilistRateLimiter } from './rate-limiter';
|
||||||
|
import { resolveAnilistSeasonMedia } from './season-resolver';
|
||||||
|
|
||||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
|
||||||
@@ -24,6 +25,12 @@ export interface AnilistPostWatchUpdateResult {
|
|||||||
export interface AnilistPostWatchUpdateOptions {
|
export interface AnilistPostWatchUpdateOptions {
|
||||||
rateLimiter?: AnilistRateLimiter;
|
rateLimiter?: AnilistRateLimiter;
|
||||||
season?: number | null;
|
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 {
|
interface AnilistGraphQlError {
|
||||||
@@ -35,23 +42,15 @@ interface AnilistGraphQlResponse<T> {
|
|||||||
errors?: AnilistGraphQlError[];
|
errors?: AnilistGraphQlError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnilistSearchData {
|
|
||||||
Page?: {
|
|
||||||
media?: Array<{
|
|
||||||
id: number;
|
|
||||||
episodes: number | null;
|
|
||||||
title?: {
|
|
||||||
romaji?: string | null;
|
|
||||||
english?: string | null;
|
|
||||||
native?: string | null;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnilistMediaEntryData {
|
interface AnilistMediaEntryData {
|
||||||
Media?: {
|
Media?: {
|
||||||
id: number;
|
id: number;
|
||||||
|
episodes?: number | null;
|
||||||
|
title?: {
|
||||||
|
romaji?: string | null;
|
||||||
|
english?: string | null;
|
||||||
|
native?: string | null;
|
||||||
|
} | null;
|
||||||
mediaListEntry?: {
|
mediaListEntry?: {
|
||||||
progress?: number | null;
|
progress?: number | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
@@ -154,32 +153,6 @@ function buildGuessitTitle(title: string, alternativeTitle: string | null): stri
|
|||||||
return title;
|
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>(
|
async function anilistGraphQl<T>(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -216,38 +189,22 @@ function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | nul
|
|||||||
return firstError?.message ?? null;
|
return firstError?.message ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickBestSearchResult(
|
/** The season resolver signals failure by throwing; anilistGraphQl reports it in-band. */
|
||||||
title: string,
|
function createAnilistSeasonQueryExecutor(
|
||||||
episode: number,
|
accessToken: string,
|
||||||
media: Array<{
|
options: AnilistPostWatchUpdateOptions,
|
||||||
id: number;
|
) {
|
||||||
episodes: number | null;
|
return async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
|
||||||
title?: {
|
const response = await anilistGraphQl<T>(accessToken, query, variables, options);
|
||||||
romaji?: string | null;
|
const error = firstErrorMessage(response);
|
||||||
english?: string | null;
|
if (error) {
|
||||||
native?: string | null;
|
throw new Error(error);
|
||||||
};
|
}
|
||||||
}>,
|
if (!response.data) {
|
||||||
): { id: number; title: string; episodes: number | null } | null {
|
throw new Error('AniList response missing data');
|
||||||
const filtered = media.filter((item) => {
|
}
|
||||||
const totalEpisodes = item.episodes;
|
return response.data;
|
||||||
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 {
|
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||||
@@ -321,52 +278,53 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
episode: number,
|
episode: number,
|
||||||
options: AnilistPostWatchUpdateOptions = {},
|
options: AnilistPostWatchUpdateOptions = {},
|
||||||
): Promise<AnilistPostWatchUpdateResult> {
|
): Promise<AnilistPostWatchUpdateResult> {
|
||||||
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
|
const pinnedMediaId =
|
||||||
let searchError: string | null = null;
|
typeof options.mediaId === 'number' && Number.isInteger(options.mediaId) && options.mediaId > 0
|
||||||
let pickTitle = title;
|
? options.mediaId
|
||||||
const searchCandidates = buildSearchCandidates(title, options.season);
|
: null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchError) {
|
let mediaId = pinnedMediaId;
|
||||||
return {
|
let resolvedTitle: string | null = null;
|
||||||
status: 'error',
|
let resolvedEpisodes: number | null = null;
|
||||||
message: `AniList search failed: ${searchError}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const picked = pickBestSearchResult(pickTitle, episode, media);
|
if (mediaId === null) {
|
||||||
if (!picked) {
|
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
|
||||||
return { status: 'error', message: 'AniList search returned no matches.' };
|
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: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>(
|
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
|
||||||
@@ -375,6 +333,12 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
query ($mediaId: Int!) {
|
query ($mediaId: Int!) {
|
||||||
Media(id: $mediaId, type: ANIME) {
|
Media(id: $mediaId, type: ANIME) {
|
||||||
id
|
id
|
||||||
|
episodes
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
mediaListEntry {
|
mediaListEntry {
|
||||||
progress
|
progress
|
||||||
status
|
status
|
||||||
@@ -382,7 +346,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ mediaId: picked.id },
|
{ mediaId },
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
const entryError = firstErrorMessage(entryResponse);
|
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)) {
|
if (!entry || !isUpdateableListStatus(entry.status)) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
retryable: false,
|
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 currentProgress = entry.progress ?? 0;
|
||||||
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
|
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
|
||||||
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
||||||
return {
|
return {
|
||||||
status: 'skipped',
|
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,
|
progress: episode,
|
||||||
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
||||||
},
|
},
|
||||||
@@ -436,7 +413,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
return {
|
return {
|
||||||
status: 'updated',
|
status: 'updated',
|
||||||
message: shouldMarkCompleted
|
message: shouldMarkCompleted
|
||||||
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
|
? `AniList updated "${pickedTitle}" to episode ${episode} and marked it completed.`
|
||||||
: `AniList updated "${picked.title}" to episode ${episode}.`,
|
: `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 searchCalls: Array<{ search: string }> = [];
|
||||||
|
const relationCalls: number[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const raw = (init?.body as string | undefined) ?? '';
|
const raw = (init?.body as string | undefined) ?? '';
|
||||||
const payload = JSON.parse(raw) as { variables: { search: string } };
|
const payload = JSON.parse(raw) as { variables: { search?: string; id?: number } };
|
||||||
const search = payload.variables.search;
|
|
||||||
searchCalls.push({ search });
|
|
||||||
|
|
||||||
if (search.includes('Season 2')) {
|
if (typeof payload.variables.id === 'number') {
|
||||||
return Promise.resolve(createJsonResponse({ data: { Page: { media: [] } } }));
|
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(
|
return Promise.resolve(
|
||||||
createJsonResponse({
|
createJsonResponse({
|
||||||
data: {
|
data: {
|
||||||
@@ -220,6 +248,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
|||||||
{
|
{
|
||||||
id: 19,
|
id: 19,
|
||||||
episodes: 24,
|
episodes: 24,
|
||||||
|
format: 'TV',
|
||||||
|
seasonYear: 2013,
|
||||||
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
|
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
|
||||||
title: {
|
title: {
|
||||||
romaji: 'Little Witch Academia',
|
romaji: 'Little Witch Academia',
|
||||||
@@ -251,9 +281,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
|||||||
const stored = getCoverArt(db, videoId);
|
const stored = getCoverArt(db, videoId);
|
||||||
|
|
||||||
assert.equal(fetched, true);
|
assert.equal(fetched, true);
|
||||||
assert.equal(searchCalls.length, 2);
|
// One search on the bare title, then a sequel hop to reach season 2.
|
||||||
assert.equal(searchCalls[0]!.search, 'Little Witch Academia Season 2');
|
assert.equal(searchCalls.length, 1);
|
||||||
assert.equal(stored?.anilistId, 19);
|
assert.equal(searchCalls[0]!.search, 'Little Witch Academia');
|
||||||
|
assert.deepEqual(relationCalls, [19]);
|
||||||
|
assert.equal(stored?.anilistId, 20);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
db.close();
|
db.close();
|
||||||
@@ -324,3 +356,187 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
|
|||||||
cleanupDbPath(dbPath);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,43 +11,15 @@ import {
|
|||||||
runGuessit,
|
runGuessit,
|
||||||
type GuessAnilistMediaInfoDeps,
|
type GuessAnilistMediaInfoDeps,
|
||||||
} from './anilist-updater';
|
} from './anilist-updater';
|
||||||
|
import {
|
||||||
|
resolveAnilistSeasonMedia,
|
||||||
|
type AnilistQueryExecutor,
|
||||||
|
type AnilistSeasonResolution,
|
||||||
|
} from './season-resolver';
|
||||||
|
|
||||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
|
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 {
|
export interface CoverArtFetcher {
|
||||||
fetchIfMissing(db: DatabaseSync, videoId: number, canonicalTitle: string): Promise<boolean>;
|
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, ' ');
|
return title.trim().replace(/\s{2,}/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSeasonHint(title: string): string {
|
class AnilistRateLimitedError extends Error {
|
||||||
return title
|
constructor() {
|
||||||
.replace(/\bseason\s*\d+\b/gi, '')
|
super('Anilist rate limit reached');
|
||||||
.replace(/\s{2,}/g, ' ')
|
this.name = 'AnilistRateLimitedError';
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
async function executeAnilistQuery<T>(
|
||||||
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(
|
|
||||||
rateLimiter: AnilistRateLimiter,
|
rateLimiter: AnilistRateLimiter,
|
||||||
title: string,
|
query: string,
|
||||||
): Promise<{ media: AnilistMedia[]; rateLimited: boolean }> {
|
variables: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
await rateLimiter.acquire();
|
await rateLimiter.acquire();
|
||||||
|
|
||||||
const res = await fetch(ANILIST_GRAPHQL_URL, {
|
const res = await fetch(ANILIST_GRAPHQL_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
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);
|
rateLimiter.recordResponse(res.headers);
|
||||||
|
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
return { media: [], rateLimited: true };
|
throw new AnilistRateLimitedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
|
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = (await res.json()) as AnilistSearchResponse;
|
const json = (await res.json()) as { data?: T; errors?: Array<{ message?: string }> };
|
||||||
const mediaList = json.data?.Page?.media;
|
const firstError = json.errors?.find((entry) => Boolean(entry?.message));
|
||||||
if (!mediaList || mediaList.length === 0) {
|
if (firstError?.message) {
|
||||||
return { media: [], rateLimited: false };
|
throw new Error(firstError.message);
|
||||||
}
|
}
|
||||||
|
if (!json.data) {
|
||||||
return { media: mediaList, rateLimited: false };
|
throw new Error('Anilist response missing data');
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadImage(url: string): Promise<Buffer | null> {
|
async function downloadImage(url: string): Promise<Buffer | null> {
|
||||||
@@ -376,49 +242,57 @@ export function createCoverArtFetcher(
|
|||||||
|
|
||||||
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
|
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
|
||||||
const searchBase = parsedInfo?.title ?? cleaned;
|
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)
|
const execute: AnilistQueryExecutor = (query, variables) =>
|
||||||
? searchCandidates
|
executeAnilistQuery(rateLimiter, query, variables);
|
||||||
: [...searchCandidates, cleaned];
|
|
||||||
|
|
||||||
let selected: AnilistMedia | null = null;
|
let resolution: AnilistSeasonResolution | null = null;
|
||||||
let rateLimited = false;
|
try {
|
||||||
|
for (const searchTitle of searchTitles) {
|
||||||
for (const candidate of effectiveCandidates) {
|
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', searchTitle, videoId);
|
||||||
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', candidate, videoId);
|
resolution = await resolveAnilistSeasonMedia(
|
||||||
|
{
|
||||||
try {
|
title: searchTitle,
|
||||||
const result = await searchAnilist(rateLimiter, candidate);
|
season: parsedInfo?.season ?? null,
|
||||||
rateLimited = result.rateLimited;
|
episode: parsedInfo?.episode ?? null,
|
||||||
if (result.media.length === 0) {
|
},
|
||||||
continue;
|
{ execute, logInfo: (message) => logger.info('%s', message) },
|
||||||
}
|
|
||||||
|
|
||||||
const picked = pickBestSearchResult(
|
|
||||||
searchBase,
|
|
||||||
parsedInfo?.episode ?? null,
|
|
||||||
parsedInfo?.season ?? null,
|
|
||||||
result.media,
|
|
||||||
);
|
);
|
||||||
if (picked) {
|
if (resolution) break;
|
||||||
const match = result.media.find((media) => media.id === picked.id);
|
}
|
||||||
if (match) {
|
} catch (err) {
|
||||||
selected = match;
|
if (err instanceof AnilistRateLimitedError) {
|
||||||
break;
|
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('cover-art: Anilist search error for "%s": %s', candidate, err);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
logger.error('cover-art: Anilist search error for "%s": %s', searchBase, err);
|
||||||
|
|
||||||
if (rateLimited) {
|
|
||||||
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
|
|
||||||
return false;
|
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) {
|
if (!selected) {
|
||||||
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
|
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
|
||||||
upsertCoverArt(db, videoId, {
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assOverrideSignature,
|
||||||
|
assToPlainText,
|
||||||
|
collectAssOverrideCommands,
|
||||||
|
extractAssOverrideBlocks,
|
||||||
|
hasAssTemporalOverride,
|
||||||
|
isAnimatedAssEffectKind,
|
||||||
|
isAssTemporalCommand,
|
||||||
|
normalizePlainSubtitleText,
|
||||||
|
parseAssEffectField,
|
||||||
|
} from './ass-text';
|
||||||
|
|
||||||
|
test('assToPlainText drops vector drawing runs', () => {
|
||||||
|
assert.equal(
|
||||||
|
assToPlainText(
|
||||||
|
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
||||||
|
assert.equal(
|
||||||
|
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
||||||
|
'本文続き',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
||||||
|
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
||||||
|
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText resolves line-break and space escapes', () => {
|
||||||
|
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
||||||
|
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
||||||
|
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
||||||
|
// braces still open and close an override block.
|
||||||
|
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
||||||
|
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText renders an unclosed override block verbatim', () => {
|
||||||
|
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
||||||
|
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText is idempotent', () => {
|
||||||
|
const samples = [
|
||||||
|
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
||||||
|
'\\{注\\}',
|
||||||
|
'\\\\N',
|
||||||
|
'本文{\\pos(1,2)',
|
||||||
|
'一行目\\N二行目\\h終わり',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const once = assToPlainText(sample);
|
||||||
|
assert.equal(assToPlainText(once), once, sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText normalizes CRLF before converting', () => {
|
||||||
|
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
||||||
|
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||||
|
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(
|
||||||
|
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
||||||
|
'一行目 二行目',
|
||||||
|
);
|
||||||
|
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePlainSubtitleText is idempotent', () => {
|
||||||
|
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
||||||
|
const once = normalizePlainSubtitleText(sample);
|
||||||
|
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractAssOverrideBlocks returns block contents', () => {
|
||||||
|
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
||||||
|
'\\an8',
|
||||||
|
'\\fad(200,200)',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
||||||
|
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
{ name: 'pos', args: '1,2', animated: false },
|
||||||
|
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
||||||
|
{ name: 'kf', args: '30', animated: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
||||||
|
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
||||||
|
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
commands.map((command) => [command.name, command.animated]),
|
||||||
|
[
|
||||||
|
['clip', false],
|
||||||
|
['t', false],
|
||||||
|
['frz', true],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(hasAssTemporalOverride(commands), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
|
||||||
|
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
|
||||||
|
// pathological line (real files reach one or two levels) overflows the stack.
|
||||||
|
const nesting = 32;
|
||||||
|
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
|
||||||
|
|
||||||
|
const commands = collectAssOverrideCommands(block);
|
||||||
|
|
||||||
|
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
|
||||||
|
assert.equal(commands.length, 9);
|
||||||
|
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
|
||||||
|
assert.equal(hasAssTemporalOverride(commands), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||||
|
assert.equal(
|
||||||
|
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
||||||
|
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
||||||
|
assert.equal(isAssTemporalCommand(command), true, command);
|
||||||
|
}
|
||||||
|
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
||||||
|
assert.equal(isAssTemporalCommand(command), false, command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assOverrideSignature distinguishes events by their override values', () => {
|
||||||
|
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
||||||
|
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
||||||
|
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
||||||
|
|
||||||
|
assert.notEqual(first, second);
|
||||||
|
assert.equal(first, repeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssEffectField classifies the event-level Effect column', () => {
|
||||||
|
assert.equal(parseAssEffectField(''), 'none');
|
||||||
|
assert.equal(parseAssEffectField(' '), 'none');
|
||||||
|
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
||||||
|
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
||||||
|
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
||||||
|
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
||||||
|
assert.equal(parseAssEffectField('fx-template'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssEffectField matches stock effect names exactly', () => {
|
||||||
|
// Custom effect names that merely start with a stock name are not stock effects.
|
||||||
|
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('Scroll'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
||||||
|
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
||||||
|
// Typesetting groups put static template names in this column too.
|
||||||
|
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('none'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
* ASS/SSA text handling, split into two deliberately distinct contracts:
|
||||||
|
*
|
||||||
|
* assToPlainText() raw ASS event text -> plain text. Ingestion only.
|
||||||
|
* normalizePlainSubtitleText() already-decoded text -> display/lookup form.
|
||||||
|
*
|
||||||
|
* Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
|
||||||
|
* file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
|
||||||
|
* (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
|
||||||
|
* downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
|
||||||
|
* plain text and only normalizes whitespace, so no layer decodes the same string twice.
|
||||||
|
*
|
||||||
|
* assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
|
||||||
|
* so a cue parsed from a file reads the same as the same line arriving live:
|
||||||
|
* - `{...}` override blocks are markup
|
||||||
|
* - `\pN ... \p0` runs are vector paths, not dialogue
|
||||||
|
* - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
|
||||||
|
* so `\{注\}` decodes to a lone backslash exactly as mpv renders it
|
||||||
|
* - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
|
||||||
|
* Because the decoder never emits an escape or a closed brace, running it twice is a
|
||||||
|
* no-op -- but downstream code should still use normalizePlainSubtitleText.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What `\N` and `\n` become. */
|
||||||
|
export type AssLineBreak = '\n' | ' ';
|
||||||
|
|
||||||
|
// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
|
||||||
|
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
|
||||||
|
// `\pos(...)` from being read as a drawing tag.
|
||||||
|
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;
|
||||||
|
|
||||||
|
function readDrawingScale(block: string): number | null {
|
||||||
|
ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
|
||||||
|
let scale: number | null = null;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
// Drawing mode is whatever the last `\p` tag in this block set it to.
|
||||||
|
while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
|
||||||
|
scale = match[1] ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
|
||||||
|
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
|
||||||
|
return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
|
||||||
|
escaped === 'h' ? ' ' : lineBreak,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip `{...}` override blocks and the drawing runs they enable. */
|
||||||
|
function stripAssMarkup(raw: string): string {
|
||||||
|
let out = '';
|
||||||
|
let cursor = 0;
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
while (cursor < raw.length) {
|
||||||
|
if (raw[cursor] !== '{') {
|
||||||
|
if (!drawing) {
|
||||||
|
out += raw[cursor];
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = raw.indexOf('}', cursor + 1);
|
||||||
|
if (close === -1) {
|
||||||
|
// mpv shows an unclosed `{` and everything after it. Guessing where the block was
|
||||||
|
// meant to end can eat a whole line of dialogue.
|
||||||
|
if (!drawing) {
|
||||||
|
out += raw.slice(cursor);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = readDrawingScale(raw.slice(cursor, close + 1));
|
||||||
|
if (scale !== null) {
|
||||||
|
drawing = scale > 0;
|
||||||
|
}
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
|
||||||
|
* downstream layers take the result as plain text.
|
||||||
|
*/
|
||||||
|
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
|
||||||
|
if (!text) return '';
|
||||||
|
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizePlainSubtitleTextOptions {
|
||||||
|
/** Fold every line break into a single space. */
|
||||||
|
collapseLineBreaks?: boolean;
|
||||||
|
trim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitespace normalization for text that has already been decoded -- by mpv for live
|
||||||
|
* subtitles, by the cue parser for files. Override blocks and drawing runs are none of
|
||||||
|
* this function's business; a `{` that reaches here is literal text mpv chose to show.
|
||||||
|
*
|
||||||
|
* `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
|
||||||
|
* and other websocket clients) forward them raw and the display layer has to cope.
|
||||||
|
*/
|
||||||
|
export function normalizePlainSubtitleText(
|
||||||
|
text: string,
|
||||||
|
options: NormalizePlainSubtitleTextOptions = {},
|
||||||
|
): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const { collapseLineBreaks = false, trim = true } = options;
|
||||||
|
|
||||||
|
let normalized = resolveWhitespaceEscapes(
|
||||||
|
text.replace(/\r\n/g, '\n'),
|
||||||
|
collapseLineBreaks ? ' ' : '\n',
|
||||||
|
);
|
||||||
|
if (collapseLineBreaks) {
|
||||||
|
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim ? normalized.trim() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contents of each `{...}` block, without the braces. */
|
||||||
|
export function extractAssOverrideBlocks(text: string): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const open = text.indexOf('{', cursor);
|
||||||
|
if (open === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const close = text.indexOf('}', open + 1);
|
||||||
|
if (close === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
blocks.push(text.slice(open + 1, close));
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssOverrideCommand {
|
||||||
|
/** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
|
||||||
|
name: string;
|
||||||
|
/** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
|
||||||
|
args: string;
|
||||||
|
/** Nested inside a `\t(...)` argument, so its value is animated over the event. */
|
||||||
|
animated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;
|
||||||
|
|
||||||
|
function readCommandArgs(block: string, start: number): { args: string; next: number } {
|
||||||
|
if (block[start] === '(') {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = start; i < block.length; i += 1) {
|
||||||
|
if (block[i] === '(') depth += 1;
|
||||||
|
else if (block[i] === ')') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return { args: block.slice(start + 1, i), next: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { args: block.slice(start + 1), next: block.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTag = block.indexOf('\\', start);
|
||||||
|
const end = nextTag === -1 ? block.length : nextTag;
|
||||||
|
return { args: block.slice(start, end), next: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
|
||||||
|
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
|
||||||
|
// or two levels, so stop recursing well before the call stack is at risk.
|
||||||
|
const MAX_ANIMATION_NESTING_DEPTH = 8;
|
||||||
|
|
||||||
|
function parseOverrideBlock(
|
||||||
|
block: string,
|
||||||
|
animated: boolean,
|
||||||
|
into: AssOverrideCommand[],
|
||||||
|
depth = 0,
|
||||||
|
): void {
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < block.length) {
|
||||||
|
if (block[cursor] !== '\\') {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
|
||||||
|
const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
|
||||||
|
if (!nameMatch) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = nameMatch[0];
|
||||||
|
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
|
||||||
|
into.push({ name, args: args.trim(), animated });
|
||||||
|
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
||||||
|
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
|
||||||
|
parseOverrideBlock(args, true, into, depth + 1);
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override commands with their arguments, in source order. Only `{...}` blocks are
|
||||||
|
* inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
|
||||||
|
*/
|
||||||
|
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
|
||||||
|
const commands: AssOverrideCommand[] = [];
|
||||||
|
for (const block of extractAssOverrideBlocks(text)) {
|
||||||
|
parseOverrideBlock(block, false, commands);
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
|
||||||
|
// karaoke tags advance a highlight across the event's own duration. Everything else --
|
||||||
|
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
|
||||||
|
// presence says nothing about whether neighbouring events form one animation.
|
||||||
|
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);
|
||||||
|
|
||||||
|
export function isAssTemporalCommand(name: string): boolean {
|
||||||
|
return ASS_TEMPORAL_COMMANDS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
|
||||||
|
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
|
||||||
|
return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical form of an event's override values, for comparing consecutive events. Two
|
||||||
|
* events with the same signature were typeset identically, so neither is a frame of an
|
||||||
|
* animation the other belongs to.
|
||||||
|
*/
|
||||||
|
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
|
||||||
|
return commands.map((command) => `${command.name}(${command.args})`).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';
|
||||||
|
|
||||||
|
// The stock effects, matched exactly. Typesetting groups put their own template names in
|
||||||
|
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
|
||||||
|
// prefix match would hand out animation evidence to arbitrary custom effects.
|
||||||
|
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
|
||||||
|
['banner', 'banner'],
|
||||||
|
['scroll up', 'scroll'],
|
||||||
|
['scroll down', 'scroll'],
|
||||||
|
['karaoke', 'karaoke'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
|
||||||
|
* `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
|
||||||
|
* `other`.
|
||||||
|
*/
|
||||||
|
export function parseAssEffectField(raw: string): AssEffectKind {
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
if (!value) return 'none';
|
||||||
|
|
||||||
|
const name = value.split(';', 1)[0]!.trim();
|
||||||
|
return STOCK_ASS_EFFECTS.get(name) ?? 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);
|
||||||
|
|
||||||
|
export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
|
||||||
|
return ANIMATED_ASS_EFFECT_KINDS.has(kind);
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||||
|
|||||||
@@ -1414,6 +1414,353 @@ test('deleteSession ignores the currently active session and keeps new writes fl
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleteSession yields the main event loop while delete maintenance is pending', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const deleteGate: { release?: () => void } = {};
|
||||||
|
let deleteRunnerCalled = false;
|
||||||
|
let bufferedWritesAtDeleteStart = -1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
const createdTracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
deleteRunnerCalled = true;
|
||||||
|
bufferedWritesAtDeleteStart = (tracker as unknown as { queue: unknown[] }).queue.length;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
deleteGate.release = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tracker = createdTracker;
|
||||||
|
createdTracker.handleMediaChange('/tmp/delete-yield-first.mkv', 'Delete Yield First');
|
||||||
|
createdTracker.handleMediaChange('/tmp/delete-yield-active.mkv', 'Delete Yield Active');
|
||||||
|
|
||||||
|
const privateApi = createdTracker as unknown as {
|
||||||
|
db: DatabaseSync;
|
||||||
|
queue: unknown[];
|
||||||
|
flushNow: () => void;
|
||||||
|
};
|
||||||
|
const sessionId = (
|
||||||
|
privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id AS sessionId
|
||||||
|
FROM imm_sessions
|
||||||
|
WHERE ended_at_ms IS NOT NULL
|
||||||
|
ORDER BY session_id
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get() as { sessionId: number } | null
|
||||||
|
)?.sessionId;
|
||||||
|
assert.ok(sessionId);
|
||||||
|
|
||||||
|
const deletePromise = createdTracker.deleteSession(sessionId);
|
||||||
|
let timerAdvanced = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
timerAdvanced = true;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
await waitForCondition(() => deleteRunnerCalled);
|
||||||
|
assert.equal(deleteRunnerCalled, true, 'delete should be dispatched to the maintenance runner');
|
||||||
|
assert.equal(
|
||||||
|
bufferedWritesAtDeleteStart,
|
||||||
|
0,
|
||||||
|
'writes buffered before delete should flush first',
|
||||||
|
);
|
||||||
|
await waitForCondition(() => timerAdvanced);
|
||||||
|
|
||||||
|
createdTracker.recordSubtitleLine('queued during delete', 0, 1);
|
||||||
|
privateApi.flushNow();
|
||||||
|
assert.ok(privateApi.queue.length > 0, 'tracking writes should wait for delete maintenance');
|
||||||
|
|
||||||
|
assert.ok(deleteGate.release);
|
||||||
|
deleteGate.release();
|
||||||
|
await deletePromise;
|
||||||
|
} finally {
|
||||||
|
deleteGate.release?.();
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete maintenance flushes the entire write queue before locking writes', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const deleteGate: { release?: () => void } = {};
|
||||||
|
let queuedWritesAtDeleteStart = -1;
|
||||||
|
let writeLockedAtDeleteStart = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
queue: unknown[];
|
||||||
|
writeLock: { locked: boolean };
|
||||||
|
};
|
||||||
|
queuedWritesAtDeleteStart = privateApi.queue.length;
|
||||||
|
writeLockedAtDeleteStart = privateApi.writeLock.locked;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
deleteGate.release = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
batchSize: number;
|
||||||
|
flushNow: () => void;
|
||||||
|
queue: unknown[];
|
||||||
|
};
|
||||||
|
privateApi.batchSize = 1;
|
||||||
|
privateApi.queue.push({}, {}, {});
|
||||||
|
privateApi.flushNow = () => {
|
||||||
|
privateApi.queue.shift();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePromise = tracker.deleteSession(101);
|
||||||
|
await waitForCondition(() => deleteGate.release !== undefined);
|
||||||
|
|
||||||
|
assert.equal(queuedWritesAtDeleteStart, 0);
|
||||||
|
assert.equal(writeLockedAtDeleteStart, true);
|
||||||
|
|
||||||
|
deleteGate.release?.();
|
||||||
|
await deletePromise;
|
||||||
|
} finally {
|
||||||
|
deleteGate.release?.();
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete maintenance tasks stay serialized under concurrent requests', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
let activeTasks = 0;
|
||||||
|
let maxActiveTasks = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
activeTasks += 1;
|
||||||
|
maxActiveTasks = Math.max(maxActiveTasks, activeTasks);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releases.push(resolve);
|
||||||
|
});
|
||||||
|
activeTasks -= 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstDelete = tracker.deleteSession(101);
|
||||||
|
await waitForCondition(() => releases.length === 1);
|
||||||
|
assert.equal(maxActiveTasks, 1);
|
||||||
|
|
||||||
|
const secondDelete = tracker.deleteSession(102);
|
||||||
|
|
||||||
|
releases[0]?.();
|
||||||
|
await waitForCondition(() => releases.length === 2);
|
||||||
|
assert.equal(maxActiveTasks, 1);
|
||||||
|
|
||||||
|
releases[1]?.();
|
||||||
|
await Promise.all([firstDelete, secondDelete]);
|
||||||
|
} finally {
|
||||||
|
for (const release of releases) release();
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent delete requests share one maintenance worker batch', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const tasks: unknown[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async (_path, task) => {
|
||||||
|
tasks.push(task);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstDelete = tracker.deleteSession(201);
|
||||||
|
const secondDelete = tracker.deleteSessions([202, 203]);
|
||||||
|
const thirdDelete = tracker.deleteVideo(204);
|
||||||
|
await Promise.all([firstDelete, secondDelete, thirdDelete]);
|
||||||
|
|
||||||
|
assert.equal(tasks.length, 1, 'concurrent deletes should use one maintenance pass');
|
||||||
|
assert.deepEqual(tasks[0], {
|
||||||
|
kind: 'batch',
|
||||||
|
tasks: [
|
||||||
|
{ kind: 'session', sessionId: 201 },
|
||||||
|
{ kind: 'sessions', sessionIds: [202, 203] },
|
||||||
|
{ kind: 'video', videoId: 204 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy rejects delete requests waiting behind active maintenance', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
let releaseFirstTask: () => void = () => {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
let markFirstTaskStarted: () => void = () => {};
|
||||||
|
const firstTaskStarted = new Promise<void>((resolve) => {
|
||||||
|
markFirstTaskStarted = resolve;
|
||||||
|
});
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
markFirstTaskStarted();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseFirstTask = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstDelete = tracker.deleteSession(301);
|
||||||
|
await firstTaskStarted;
|
||||||
|
const queuedDelete = tracker.deleteSession(302);
|
||||||
|
tracker.destroy();
|
||||||
|
|
||||||
|
const queuedOutcome = await Promise.race([
|
||||||
|
queuedDelete.then(
|
||||||
|
() => 'resolved',
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof Error && /shutting down/.test(error.message)
|
||||||
|
? 'rejected'
|
||||||
|
: 'wrong-error',
|
||||||
|
),
|
||||||
|
new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 25)),
|
||||||
|
]);
|
||||||
|
assert.equal(queuedOutcome, 'rejected');
|
||||||
|
releaseFirstTask();
|
||||||
|
await firstDelete;
|
||||||
|
} finally {
|
||||||
|
releaseFirstTask();
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete requested after destroy rejects without running maintenance', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let maintenanceCalls = 0;
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
const tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
maintenanceCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
tracker.destroy();
|
||||||
|
|
||||||
|
await assert.rejects(tracker.deleteSession(303), /shutting down/);
|
||||||
|
assert.equal(maintenanceCalls, 0);
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteSessions skips maintenance when no sessions are deletable', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const tasks: unknown[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async (_path, task) => {
|
||||||
|
tasks.push(task);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tracker.deleteSessions([]);
|
||||||
|
|
||||||
|
assert.deepEqual(tasks, []);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued video delete is skipped when that video becomes active before dispatch', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const tasks: Array<{ kind: string }> = [];
|
||||||
|
let releaseFirstTask: () => void = () => {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
const createdTracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async (_path, task) => {
|
||||||
|
tasks.push(task);
|
||||||
|
if (tasks.length === 1) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseFirstTask = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tracker = createdTracker;
|
||||||
|
createdTracker.handleMediaChange('/tmp/delete-race-target.mkv', 'Delete Race Target');
|
||||||
|
createdTracker.handleMediaChange('/tmp/delete-race-other.mkv', 'Delete Race Other');
|
||||||
|
|
||||||
|
const privateApi = createdTracker as unknown as { db: DatabaseSync };
|
||||||
|
const targetVideoId = (
|
||||||
|
privateApi.db
|
||||||
|
.prepare(`SELECT video_id AS videoId FROM imm_videos WHERE video_key LIKE '%target.mkv'`)
|
||||||
|
.get() as { videoId: number } | null
|
||||||
|
)?.videoId;
|
||||||
|
assert.ok(targetVideoId);
|
||||||
|
|
||||||
|
const firstDelete = createdTracker.deleteSession(999_001);
|
||||||
|
await waitForCondition(() => tasks.length === 1);
|
||||||
|
|
||||||
|
const queuedVideoDelete = createdTracker.deleteVideo(targetVideoId);
|
||||||
|
createdTracker.handleMediaChange('/tmp/delete-race-target.mkv', 'Delete Race Target');
|
||||||
|
releaseFirstTask();
|
||||||
|
await Promise.all([firstDelete, queuedVideoDelete]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
tasks.map((task) => task.kind),
|
||||||
|
['session'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
releaseFirstTask();
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('deleteVideo ignores the currently active video and keeps new writes flushable', async () => {
|
test('deleteVideo ignores the currently active video and keeps new writes flushable', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
@@ -1466,6 +1813,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 () => {
|
test('handleMediaChange links parsed anime metadata on the active video row', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -83,13 +83,15 @@ import {
|
|||||||
} from './immersion-tracker/query-library';
|
} from './immersion-tracker/query-library';
|
||||||
import {
|
import {
|
||||||
cleanupVocabularyStats,
|
cleanupVocabularyStats,
|
||||||
deleteSession as deleteSessionQuery,
|
|
||||||
deleteSessions as deleteSessionsQuery,
|
|
||||||
deleteVideo as deleteVideoQuery,
|
|
||||||
getVideoDurationMs,
|
getVideoDurationMs,
|
||||||
markVideoWatched,
|
markVideoWatched,
|
||||||
upsertCoverArt,
|
upsertCoverArt,
|
||||||
} from './immersion-tracker/query-maintenance';
|
} from './immersion-tracker/query-maintenance';
|
||||||
|
import {
|
||||||
|
DeleteMaintenanceWorkerRuntime,
|
||||||
|
type RunDeleteMaintenanceTask,
|
||||||
|
} from './immersion-tracker/delete-maintenance-worker-runtime';
|
||||||
|
import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler';
|
||||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||||
import {
|
import {
|
||||||
repairLegacySeasonlessAnimeRows,
|
repairLegacySeasonlessAnimeRows,
|
||||||
@@ -181,6 +183,7 @@ const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
|
|||||||
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
||||||
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
||||||
const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000;
|
const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const DELETE_MAINTENANCE_BATCH_WINDOW_MS = 10;
|
||||||
|
|
||||||
function isValidYouTubeVideoId(value: string | null): boolean {
|
function isValidYouTubeVideoId(value: string | null): boolean {
|
||||||
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
||||||
@@ -384,6 +387,8 @@ export class ImmersionTrackerService {
|
|||||||
private readonly vacuumIntervalMs: number;
|
private readonly vacuumIntervalMs: number;
|
||||||
private readonly dbPath: string;
|
private readonly dbPath: string;
|
||||||
private readonly writeLock = { locked: false };
|
private readonly writeLock = { locked: false };
|
||||||
|
private readonly destroyDeleteMaintenanceRunner: () => void;
|
||||||
|
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
|
||||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private flushScheduled = false;
|
private flushScheduled = false;
|
||||||
@@ -405,9 +410,38 @@ export class ImmersionTrackerService {
|
|||||||
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
constructor(options: ImmersionTrackerOptions) {
|
constructor(
|
||||||
|
options: ImmersionTrackerOptions,
|
||||||
|
dependencies: {
|
||||||
|
runDeleteMaintenanceTask?: RunDeleteMaintenanceTask;
|
||||||
|
destroyDeleteMaintenanceRunner?: () => void;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
this.dbPath = options.dbPath;
|
this.dbPath = options.dbPath;
|
||||||
this.resolveLegacyVocabularyPos = options.resolveLegacyVocabularyPos;
|
this.resolveLegacyVocabularyPos = options.resolveLegacyVocabularyPos;
|
||||||
|
let runDeleteMaintenanceTask: RunDeleteMaintenanceTask;
|
||||||
|
if (dependencies.runDeleteMaintenanceTask) {
|
||||||
|
runDeleteMaintenanceTask = dependencies.runDeleteMaintenanceTask;
|
||||||
|
this.destroyDeleteMaintenanceRunner =
|
||||||
|
dependencies.destroyDeleteMaintenanceRunner ?? (() => {});
|
||||||
|
} else {
|
||||||
|
const deleteMaintenanceRuntime = new DeleteMaintenanceWorkerRuntime();
|
||||||
|
runDeleteMaintenanceTask = (dbPath, task) => deleteMaintenanceRuntime.run(dbPath, task);
|
||||||
|
this.destroyDeleteMaintenanceRunner = () => deleteMaintenanceRuntime.destroy();
|
||||||
|
}
|
||||||
|
this.deleteMaintenanceScheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: DELETE_MAINTENANCE_BATCH_WINDOW_MS,
|
||||||
|
runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task),
|
||||||
|
onBusy: () => {
|
||||||
|
this.flushTelemetry(true);
|
||||||
|
while (this.queue.length > 0) this.flushNow();
|
||||||
|
this.writeLock.locked = true;
|
||||||
|
},
|
||||||
|
onIdle: () => {
|
||||||
|
this.writeLock.locked = false;
|
||||||
|
if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
const parentDir = path.dirname(this.dbPath);
|
const parentDir = path.dirname(this.dbPath);
|
||||||
if (!fs.existsSync(parentDir)) {
|
if (!fs.existsSync(parentDir)) {
|
||||||
fs.mkdirSync(parentDir, { recursive: true });
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
@@ -511,6 +545,8 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
this.finalizeActiveSession();
|
this.finalizeActiveSession();
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
|
this.deleteMaintenanceScheduler.destroy();
|
||||||
|
this.destroyDeleteMaintenanceRunner();
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,29 +744,66 @@ export class ImmersionTrackerService {
|
|||||||
this.logger.warn(`Ignoring delete request for active immersion session ${sessionId}`);
|
this.logger.warn(`Ignoring delete request for active immersion session ${sessionId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteSessionQuery(this.db, sessionId);
|
await this.enqueueDeleteMaintenanceTask(() => ({ kind: 'session', sessionId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSessions(sessionIds: number[]): Promise<void> {
|
async deleteSessions(sessionIds: number[]): Promise<void> {
|
||||||
const activeSessionId = this.sessionState?.sessionId;
|
await this.enqueueDeleteMaintenanceTask(() => {
|
||||||
const deletableSessionIds =
|
const activeSessionId = this.sessionState?.sessionId;
|
||||||
activeSessionId === undefined
|
const deletableSessionIds =
|
||||||
? sessionIds
|
activeSessionId === undefined
|
||||||
: sessionIds.filter((sessionId) => sessionId !== activeSessionId);
|
? sessionIds
|
||||||
if (deletableSessionIds.length !== sessionIds.length) {
|
: sessionIds.filter((sessionId) => sessionId !== activeSessionId);
|
||||||
this.logger.warn(
|
if (deletableSessionIds.length !== sessionIds.length) {
|
||||||
`Ignoring bulk delete request for active immersion session ${activeSessionId}`,
|
this.logger.warn(
|
||||||
);
|
`Ignoring bulk delete request for active immersion session ${activeSessionId}`,
|
||||||
}
|
);
|
||||||
deleteSessionsQuery(this.db, deletableSessionIds);
|
}
|
||||||
|
if (deletableSessionIds.length === 0) return null;
|
||||||
|
return { kind: 'sessions', sessionIds: deletableSessionIds };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteVideo(videoId: number): Promise<void> {
|
async deleteVideo(videoId: number): Promise<void> {
|
||||||
if (this.sessionState?.videoId === videoId) {
|
await this.enqueueDeleteMaintenanceTask(() => {
|
||||||
this.logger.warn(`Ignoring delete request for active immersion video ${videoId}`);
|
if (this.sessionState?.videoId === videoId) {
|
||||||
return;
|
this.logger.warn(`Ignoring delete request for active immersion video ${videoId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { kind: 'video', videoId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAnime(animeId: number): Promise<void> {
|
||||||
|
await this.enqueueDeleteMaintenanceTask(async () => {
|
||||||
|
// Resolve this at dispatch time because another queued delete can leave
|
||||||
|
// enough time for playback to switch to an episode of this anime.
|
||||||
|
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 null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'anime', animeId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueDeleteMaintenanceTask(
|
||||||
|
resolveTask: Parameters<DeleteMaintenanceScheduler['enqueue']>[0],
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.isDestroyed) {
|
||||||
|
return Promise.reject(new Error('Immersion tracker is shutting down'));
|
||||||
}
|
}
|
||||||
deleteVideoQuery(this.db, videoId);
|
return this.deleteMaintenanceScheduler.enqueue(resolveTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reassignAnimeAnilist(
|
async reassignAnimeAnilist(
|
||||||
@@ -1788,7 +1861,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private runMaintenance(): void {
|
private runMaintenance(): void {
|
||||||
if (this.isDestroyed) return;
|
if (this.isDestroyed || this.writeLock.locked) return;
|
||||||
try {
|
try {
|
||||||
this.flushTelemetry(true);
|
this.flushTelemetry(true);
|
||||||
this.flushNow();
|
this.flushNow();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
updateAnimeAnilistInfo,
|
updateAnimeAnilistInfo,
|
||||||
upsertCoverArt,
|
upsertCoverArt,
|
||||||
} from '../query-maintenance.js';
|
} from '../query-maintenance.js';
|
||||||
|
import { deleteMaintenanceBatch } from '../query-delete-maintenance.js';
|
||||||
import { getLocalEpochDay } from '../query-shared.js';
|
import { getLocalEpochDay } from '../query-shared.js';
|
||||||
import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js';
|
import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js';
|
||||||
|
|
||||||
@@ -985,3 +986,197 @@ test('split maintenance helpers delete multiple sessions and whole videos with d
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('delete maintenance batch preserves retained data across overlapping session, video, and anime targets', () => {
|
||||||
|
const { db, dbPath, stmts } = createDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const retainedAnimeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Retained Anime',
|
||||||
|
canonicalTitle: 'Retained Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
const deletedAnimeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Deleted Anime',
|
||||||
|
canonicalTitle: 'Deleted Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
const retainedVideoId = getOrCreateVideoRecord(db, 'local:/tmp/batch-retain.mkv', {
|
||||||
|
canonicalTitle: 'Batch Retain',
|
||||||
|
sourcePath: '/tmp/batch-retain.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const deletedVideoId = getOrCreateVideoRecord(db, 'local:/tmp/batch-video.mkv', {
|
||||||
|
canonicalTitle: 'Batch Video',
|
||||||
|
sourcePath: '/tmp/batch-video.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const animeVideoId = getOrCreateVideoRecord(db, 'local:/tmp/batch-anime.mkv', {
|
||||||
|
canonicalTitle: 'Batch Anime',
|
||||||
|
sourcePath: '/tmp/batch-anime.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
for (const [videoId, animeId, episode] of [
|
||||||
|
[retainedVideoId, retainedAnimeId, 1],
|
||||||
|
[deletedVideoId, retainedAnimeId, 2],
|
||||||
|
[animeVideoId, deletedAnimeId, 1],
|
||||||
|
] as const) {
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: `batch-${episode}.mkv`,
|
||||||
|
parsedTitle: animeId === retainedAnimeId ? 'Retained Anime' : 'Deleted Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: episode,
|
||||||
|
parserSource: 'test',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAtMs = 1_700_000_000_000;
|
||||||
|
const deletedSessionId = startSessionRecord(db, retainedVideoId, startedAtMs).sessionId;
|
||||||
|
const retainedSessionId = startSessionRecord(
|
||||||
|
db,
|
||||||
|
retainedVideoId,
|
||||||
|
startedAtMs + 1_000,
|
||||||
|
).sessionId;
|
||||||
|
const videoSessionId = startSessionRecord(db, deletedVideoId, startedAtMs + 2_000).sessionId;
|
||||||
|
const animeSessionId = startSessionRecord(db, animeVideoId, startedAtMs + 3_000).sessionId;
|
||||||
|
for (const [sessionId, sessionStartedAtMs] of [
|
||||||
|
[deletedSessionId, startedAtMs],
|
||||||
|
[retainedSessionId, startedAtMs + 1_000],
|
||||||
|
[videoSessionId, startedAtMs + 2_000],
|
||||||
|
[animeSessionId, startedAtMs + 3_000],
|
||||||
|
] as const) {
|
||||||
|
finalizeSessionMetrics(db, sessionId, sessionStartedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, sessionId, videoId, animeId] of [
|
||||||
|
[1, deletedSessionId, retainedVideoId, retainedAnimeId],
|
||||||
|
[2, retainedSessionId, retainedVideoId, retainedAnimeId],
|
||||||
|
[3, videoSessionId, deletedVideoId, retainedAnimeId],
|
||||||
|
[4, animeSessionId, animeVideoId, deletedAnimeId],
|
||||||
|
] as const) {
|
||||||
|
insertWordOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex: index,
|
||||||
|
text: '猫日',
|
||||||
|
word: { headword: '猫', word: '猫', reading: 'ねこ' },
|
||||||
|
});
|
||||||
|
insertKanjiOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex: index + 10,
|
||||||
|
text: '猫日',
|
||||||
|
kanji: '日',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollupDay = getLocalEpochDay(db, startedAtMs);
|
||||||
|
const rollupMonth = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT CAST(strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollupMonth`,
|
||||||
|
)
|
||||||
|
.get(startedAtMs) as { rollupMonth: number }
|
||||||
|
).rollupMonth;
|
||||||
|
for (const videoId of [retainedVideoId, deletedVideoId, animeVideoId]) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, 99, 99, 99, 99, 99, ?, ?)`,
|
||||||
|
).run(rollupDay, videoId, startedAtMs, startedAtMs);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_monthly_rollups (
|
||||||
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, 99, 99, 99, 99, 99, ?, ?)`,
|
||||||
|
).run(rollupMonth, videoId, startedAtMs, startedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMaintenanceBatch(db, [
|
||||||
|
{ kind: 'session', sessionId: deletedSessionId },
|
||||||
|
{ kind: 'session', sessionId: videoSessionId },
|
||||||
|
{ kind: 'video', videoId: deletedVideoId },
|
||||||
|
{ kind: 'video', videoId: animeVideoId },
|
||||||
|
{ kind: 'anime', animeId: deletedAnimeId },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(db.prepare('SELECT session_id FROM imm_sessions').all(), [
|
||||||
|
{ session_id: retainedSessionId },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(db.prepare('SELECT video_id FROM imm_videos').all(), [
|
||||||
|
{ video_id: retainedVideoId },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(db.prepare('SELECT anime_id FROM imm_anime').all(), [
|
||||||
|
{ anime_id: retainedAnimeId },
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
db.prepare(`SELECT frequency FROM imm_words WHERE headword = '猫'`).get() as {
|
||||||
|
frequency: number;
|
||||||
|
}
|
||||||
|
).frequency,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
db.prepare(`SELECT frequency FROM imm_kanji WHERE kanji = '日'`).get() as {
|
||||||
|
frequency: number;
|
||||||
|
}
|
||||||
|
).frequency,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
db.prepare('SELECT video_id, total_sessions FROM imm_daily_rollups').all() as Array<{
|
||||||
|
video_id: number;
|
||||||
|
total_sessions: number;
|
||||||
|
}>,
|
||||||
|
[{ video_id: retainedVideoId, total_sessions: 1 }],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
db.prepare('SELECT video_id, total_sessions FROM imm_monthly_rollups').all() as Array<{
|
||||||
|
video_id: number;
|
||||||
|
total_sessions: number;
|
||||||
|
}>,
|
||||||
|
[{ video_id: retainedVideoId, total_sessions: 1 }],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete maintenance batch chunks id lists below the SQLite variable limit', () => {
|
||||||
|
const { db, dbPath } = createDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = Array.from({ length: 32_767 }, (_, index) => index + 1);
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
deleteMaintenanceBatch(db, [
|
||||||
|
{ kind: 'sessions', sessionIds: ids },
|
||||||
|
...ids.map((videoId) => ({ kind: 'video' as const, videoId })),
|
||||||
|
...ids.map((animeId) => ({ kind: 'anime' as const, animeId })),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { Database } from '../sqlite.js';
|
import { Database } from '../sqlite.js';
|
||||||
import {
|
import {
|
||||||
|
applyPragmas,
|
||||||
createTrackerPreparedStatements,
|
createTrackerPreparedStatements,
|
||||||
ensureSchema,
|
ensureSchema,
|
||||||
getOrCreateAnimeRecord,
|
getOrCreateAnimeRecord,
|
||||||
@@ -57,6 +58,16 @@ function makeDbPath(): string {
|
|||||||
return path.join(dir, 'immersion.sqlite');
|
return path.join(dir, 'immersion.sqlite');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match the runtime's SQLite tuning (WAL + synchronous=NORMAL). Without it these
|
||||||
|
// tests run at SQLite's defaults, where every statement is its own fsync-ing
|
||||||
|
// transaction; on slow CI disks that is enough to push insert-heavy cases past
|
||||||
|
// the 5s per-test timeout.
|
||||||
|
function openTestDb(dbPath: string) {
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupDbPath(dbPath: string): void {
|
function cleanupDbPath(dbPath: string): void {
|
||||||
const dir = path.dirname(dbPath);
|
const dir = path.dirname(dbPath);
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
@@ -172,7 +183,7 @@ function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
|
|||||||
|
|
||||||
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -231,7 +242,7 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
|||||||
|
|
||||||
test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
|
test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -299,7 +310,7 @@ test('getAnimeEpisodes prefers the latest session media position when the latest
|
|||||||
|
|
||||||
test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
|
test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -397,7 +408,7 @@ test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
|
|||||||
|
|
||||||
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
|
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -469,7 +480,7 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
|
|||||||
|
|
||||||
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -541,7 +552,7 @@ test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to
|
|||||||
|
|
||||||
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -592,7 +603,7 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
|
|||||||
|
|
||||||
test('getDailyRollups limits by distinct days (not rows)', () => {
|
test('getDailyRollups limits by distinct days (not rows)', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -625,7 +636,7 @@ test('getDailyRollups limits by distinct days (not rows)', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -779,7 +790,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -869,7 +880,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1005,7 +1016,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1069,7 +1080,7 @@ test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1123,7 +1134,7 @@ test(
|
|||||||
{ timeout: 20_000 },
|
{ timeout: 20_000 },
|
||||||
() => {
|
() => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1183,7 +1194,7 @@ test(
|
|||||||
|
|
||||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1406,7 +1417,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
|
|
||||||
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1476,7 +1487,7 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
|||||||
|
|
||||||
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
|
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
withMockNowMs('1773601200000', () => {
|
withMockNowMs('1773601200000', () => {
|
||||||
try {
|
try {
|
||||||
@@ -1528,7 +1539,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
|||||||
|
|
||||||
test('word-count read models use filtered persisted occurrences with raw fallback', () => {
|
test('word-count read models use filtered persisted occurrences with raw fallback', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1611,7 +1622,7 @@ test('word-count read models use filtered persisted occurrences with raw fallbac
|
|||||||
|
|
||||||
test('rollups keep persisted totals when retained-session word counts are partial', () => {
|
test('rollups keep persisted totals when retained-session word counts are partial', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1672,7 +1683,7 @@ test('rollups keep persisted totals when retained-session word counts are partia
|
|||||||
|
|
||||||
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1737,7 +1748,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
|||||||
|
|
||||||
test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1771,7 +1782,7 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
|||||||
|
|
||||||
test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
|
test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1823,7 +1834,7 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
|
|||||||
|
|
||||||
test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1866,7 +1877,7 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
|||||||
|
|
||||||
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1921,7 +1932,7 @@ test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', ()
|
|||||||
|
|
||||||
test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
|
test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1956,7 +1967,7 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
|
|||||||
|
|
||||||
test('getVocabularyStats returns empty array when no words exist', () => {
|
test('getVocabularyStats returns empty array when no words exist', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1970,7 +1981,7 @@ test('getVocabularyStats returns empty array when no words exist', () => {
|
|||||||
|
|
||||||
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2100,7 +2111,7 @@ test('cleanupVocabularyStats repairs stored POS metadata and removes excluded im
|
|||||||
|
|
||||||
test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2136,7 +2147,7 @@ test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
|||||||
|
|
||||||
test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
|
test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2167,7 +2178,7 @@ test('getMonthlyRollups returns all rows for the most recent rollup months', ()
|
|||||||
|
|
||||||
test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
|
test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2198,7 +2209,7 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
|
|||||||
|
|
||||||
test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
|
test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2254,7 +2265,7 @@ test('getAnimeDailyRollups returns all rows for the most recent rollup days', ()
|
|||||||
|
|
||||||
test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
|
test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2367,7 +2378,7 @@ test('cleanupVocabularyStats merges repaired duplicates instead of violating the
|
|||||||
|
|
||||||
test('getKanjiStats returns rows ordered by frequency descending', () => {
|
test('getKanjiStats returns rows ordered by frequency descending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2405,7 +2416,7 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
|
|||||||
|
|
||||||
test('getKanjiStats returns empty array when no kanji exist', () => {
|
test('getKanjiStats returns empty array when no kanji exist', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2419,7 +2430,7 @@ test('getKanjiStats returns empty array when no kanji exist', () => {
|
|||||||
|
|
||||||
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2480,7 +2491,7 @@ test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
|||||||
|
|
||||||
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
|
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2524,7 +2535,7 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
|
|||||||
|
|
||||||
test('getSessionEvents returns empty array for session with no events', () => {
|
test('getSessionEvents returns empty array for session with no events', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2547,7 +2558,7 @@ test('getSessionEvents returns empty array for session with no events', () => {
|
|||||||
|
|
||||||
test('getSessionEvents filters events to the requested session id', () => {
|
test('getSessionEvents filters events to the requested session id', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2613,7 +2624,7 @@ test('getSessionEvents filters events to the requested session id', () => {
|
|||||||
|
|
||||||
test('getSessionEvents respects limit parameter', () => {
|
test('getSessionEvents respects limit parameter', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2656,7 +2667,7 @@ test('getSessionEvents respects limit parameter', () => {
|
|||||||
|
|
||||||
test('getSessionEvents filters by event type before applying limit', () => {
|
test('getSessionEvents filters by event type before applying limit', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2728,7 +2739,7 @@ test('getSessionEvents filters by event type before applying limit', () => {
|
|||||||
|
|
||||||
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
|
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2803,7 +2814,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
|
|||||||
|
|
||||||
test('anime-level queries group by anime_id and preserve episode-level rows', () => {
|
test('anime-level queries group by anime_id and preserve episode-level rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3083,7 +3094,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
|
|
||||||
test('anime library and detail still return lifetime rows without retained sessions', () => {
|
test('anime library and detail still return lifetime rows without retained sessions', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3180,7 +3191,7 @@ test('anime library and detail still return lifetime rows without retained sessi
|
|||||||
|
|
||||||
test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
|
test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3270,7 +3281,7 @@ test('anime and media detail prefer lifetime totals over partial retained sessio
|
|||||||
|
|
||||||
test('media detail resolves retained sessions before lifetime summary exists', () => {
|
test('media detail resolves retained sessions before lifetime summary exists', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3323,7 +3334,7 @@ test('media detail resolves retained sessions before lifetime summary exists', (
|
|||||||
|
|
||||||
test('media library and detail queries read lifetime totals', () => {
|
test('media library and detail queries read lifetime totals', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3415,7 +3426,7 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
|
|
||||||
test('media library and detail queries include joined youtube metadata when present', () => {
|
test('media library and detail queries include joined youtube metadata when present', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3512,7 +3523,7 @@ test('media library and detail queries include joined youtube metadata when pres
|
|||||||
|
|
||||||
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3639,7 +3650,7 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
|
|||||||
|
|
||||||
test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
|
test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3685,7 +3696,7 @@ test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', (
|
|||||||
|
|
||||||
test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
|
test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3827,7 +3838,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
|
|
||||||
test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
|
test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3902,7 +3913,7 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
|
|||||||
|
|
||||||
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3999,7 +4010,7 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co
|
|||||||
|
|
||||||
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
|
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4083,7 +4094,7 @@ test('searchSubtitleSentences searches subtitle lines by resolved headword candi
|
|||||||
|
|
||||||
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4158,7 +4169,7 @@ test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line c
|
|||||||
|
|
||||||
test('deleteSession removes the session and all associated session-scoped rows', () => {
|
test('deleteSession removes the session and all associated session-scoped rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4306,7 +4317,7 @@ test('deleteSession removes the session and all associated session-scoped rows',
|
|||||||
|
|
||||||
test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
|
test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4462,7 +4473,7 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
|||||||
|
|
||||||
test('deleteSession removes zero-session media from library and trends', () => {
|
test('deleteSession removes zero-session media from library and trends', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4667,7 +4678,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
|
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4774,7 +4785,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
|
|||||||
|
|
||||||
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
|
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4856,7 +4867,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
|
|||||||
|
|
||||||
test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
|
test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4971,7 +4982,7 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
|
|||||||
|
|
||||||
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4982,3 +4993,145 @@ test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
|||||||
cleanupDbPath(dbPath);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { DeleteMaintenanceScheduler } from './delete-maintenance-scheduler';
|
||||||
|
import type { DeleteMaintenanceTask } from './delete-maintenance';
|
||||||
|
|
||||||
|
test('scheduler batches same-turn requests and balances busy state', async () => {
|
||||||
|
const tasks: DeleteMaintenanceTask[] = [];
|
||||||
|
const states: string[] = [];
|
||||||
|
const scheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: 0,
|
||||||
|
runTask: async (task) => {
|
||||||
|
tasks.push(task);
|
||||||
|
},
|
||||||
|
onBusy: () => states.push('busy'),
|
||||||
|
onIdle: () => states.push('idle'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = scheduler.enqueue(() => ({ kind: 'session', sessionId: 1 }));
|
||||||
|
const second = scheduler.enqueue(() => ({ kind: 'sessions', sessionIds: [2, 3] }));
|
||||||
|
const third = scheduler.enqueue(() => null);
|
||||||
|
await Promise.all([first, second, third]);
|
||||||
|
|
||||||
|
assert.deepEqual(tasks, [
|
||||||
|
{
|
||||||
|
kind: 'batch',
|
||||||
|
tasks: [
|
||||||
|
{ kind: 'session', sessionId: 1 },
|
||||||
|
{ kind: 'sessions', sessionIds: [2, 3] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(states, ['busy', 'idle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduler rejects enqueue after destruction without entering busy state', async () => {
|
||||||
|
let busyCalls = 0;
|
||||||
|
let runCalls = 0;
|
||||||
|
const scheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: 0,
|
||||||
|
runTask: async () => {
|
||||||
|
runCalls += 1;
|
||||||
|
},
|
||||||
|
onBusy: () => {
|
||||||
|
busyCalls += 1;
|
||||||
|
},
|
||||||
|
onIdle: () => {},
|
||||||
|
});
|
||||||
|
scheduler.destroy();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
scheduler.enqueue(() => ({ kind: 'session', sessionId: 1 })),
|
||||||
|
/shutting down/,
|
||||||
|
);
|
||||||
|
assert.equal(busyCalls, 0);
|
||||||
|
assert.equal(runCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduler rejects every request in a batch when the maintenance task fails', async () => {
|
||||||
|
const failure = new Error('maintenance failed');
|
||||||
|
const scheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: 0,
|
||||||
|
runTask: async () => {
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
onBusy: () => {},
|
||||||
|
onIdle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = scheduler.enqueue(() => ({ kind: 'session', sessionId: 1 }));
|
||||||
|
const second = scheduler.enqueue(() => ({ kind: 'session', sessionId: 2 }));
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([first, second]);
|
||||||
|
assert.deepEqual(
|
||||||
|
results.map((result) => (result.status === 'rejected' ? result.reason : null)),
|
||||||
|
[failure, failure],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduler rejects only the request whose task resolution fails', async () => {
|
||||||
|
const failure = new Error('resolution failed');
|
||||||
|
const tasks: DeleteMaintenanceTask[] = [];
|
||||||
|
const scheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: 0,
|
||||||
|
runTask: async (task) => {
|
||||||
|
tasks.push(task);
|
||||||
|
},
|
||||||
|
onBusy: () => {},
|
||||||
|
onIdle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = scheduler.enqueue(() => {
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
const succeeded = scheduler.enqueue(() => ({ kind: 'session', sessionId: 2 }));
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([failed, succeeded]);
|
||||||
|
assert.equal(results[0]?.status, 'rejected');
|
||||||
|
assert.equal(results[0]?.status === 'rejected' ? results[0].reason : null, failure);
|
||||||
|
assert.equal(results[1]?.status, 'fulfilled');
|
||||||
|
assert.deepEqual(tasks, [{ kind: 'session', sessionId: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduler does not schedule another drain when the queue is empty', async () => {
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
let timerCalls = 0;
|
||||||
|
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||||
|
timerCalls += 1;
|
||||||
|
return originalSetTimeout(handler, timeout, ...args);
|
||||||
|
}) as typeof setTimeout;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: 0,
|
||||||
|
runTask: async () => {},
|
||||||
|
onBusy: () => {},
|
||||||
|
onIdle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await scheduler.enqueue(() => ({ kind: 'session', sessionId: 1 }));
|
||||||
|
assert.equal(timerCalls, 1);
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduler serializes batches and rejects requests queued at destruction', async () => {
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
let activeTasks = 0;
|
||||||
|
let maxActiveTasks = 0;
|
||||||
|
const scheduler = new DeleteMaintenanceScheduler({
|
||||||
|
batchWindowMs: 0,
|
||||||
|
runTask: async () => {
|
||||||
|
activeTasks += 1;
|
||||||
|
maxActiveTasks = Math.max(maxActiveTasks, activeTasks);
|
||||||
|
await new Promise<void>((resolve) => releases.push(resolve));
|
||||||
|
activeTasks -= 1;
|
||||||
|
},
|
||||||
|
onBusy: () => {},
|
||||||
|
onIdle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = scheduler.enqueue(() => ({ kind: 'session', sessionId: 1 }));
|
||||||
|
const maxPollAttempts = 100;
|
||||||
|
let pollAttempts = 0;
|
||||||
|
while (releases.length === 0 && pollAttempts < maxPollAttempts) {
|
||||||
|
pollAttempts += 1;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
releases.length > 0,
|
||||||
|
`runTask did not produce a release after ${maxPollAttempts} polling attempts`,
|
||||||
|
);
|
||||||
|
const queued = scheduler.enqueue(() => ({ kind: 'session', sessionId: 2 }));
|
||||||
|
scheduler.destroy();
|
||||||
|
|
||||||
|
await assert.rejects(queued, /shutting down/);
|
||||||
|
releases[0]?.();
|
||||||
|
await first;
|
||||||
|
assert.equal(maxActiveTasks, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { DeleteMaintenanceOperation, DeleteMaintenanceTask } from './delete-maintenance';
|
||||||
|
|
||||||
|
type ResolveDeleteMaintenanceOperation = () =>
|
||||||
|
| DeleteMaintenanceOperation
|
||||||
|
| null
|
||||||
|
| Promise<DeleteMaintenanceOperation | null>;
|
||||||
|
|
||||||
|
interface PendingDeleteMaintenanceRequest {
|
||||||
|
resolveTask: ResolveDeleteMaintenanceOperation;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteMaintenanceSchedulerOptions {
|
||||||
|
batchWindowMs: number;
|
||||||
|
runTask: (task: DeleteMaintenanceTask) => Promise<void>;
|
||||||
|
onBusy: () => void;
|
||||||
|
onIdle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteMaintenanceScheduler {
|
||||||
|
private readonly pendingRequests: PendingDeleteMaintenanceRequest[] = [];
|
||||||
|
private running = false;
|
||||||
|
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pendingTaskCount = 0;
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: DeleteMaintenanceSchedulerOptions) {}
|
||||||
|
|
||||||
|
enqueue(resolveTask: ResolveDeleteMaintenanceOperation): Promise<void> {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return Promise.reject(new Error('Immersion tracker is shutting down'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pendingTaskCount === 0) this.options.onBusy();
|
||||||
|
this.pendingTaskCount += 1;
|
||||||
|
|
||||||
|
const result = new Promise<void>((resolve, reject) => {
|
||||||
|
this.pendingRequests.push({ resolveTask, resolve, reject });
|
||||||
|
this.scheduleDrain();
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.finally(() => {
|
||||||
|
this.pendingTaskCount -= 1;
|
||||||
|
if (this.pendingTaskCount === 0) this.options.onIdle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.destroyed = true;
|
||||||
|
if (this.drainTimer) {
|
||||||
|
clearTimeout(this.drainTimer);
|
||||||
|
this.drainTimer = null;
|
||||||
|
}
|
||||||
|
const error = new Error('Immersion tracker is shutting down');
|
||||||
|
for (const request of this.pendingRequests.splice(0)) request.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleDrain(): void {
|
||||||
|
if (this.destroyed || this.running || this.drainTimer || this.pendingRequests.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.drainTimer = setTimeout(() => {
|
||||||
|
this.drainTimer = null;
|
||||||
|
void this.drain();
|
||||||
|
}, this.options.batchWindowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain(): Promise<void> {
|
||||||
|
if (this.running || this.pendingRequests.length === 0) return;
|
||||||
|
this.running = true;
|
||||||
|
const requests = this.pendingRequests.splice(0);
|
||||||
|
const runnable: Array<{
|
||||||
|
request: PendingDeleteMaintenanceRequest;
|
||||||
|
task: DeleteMaintenanceOperation;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
try {
|
||||||
|
const task = await request.resolveTask();
|
||||||
|
if (task) runnable.push({ request, task });
|
||||||
|
else request.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
request.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runnable.length > 0) {
|
||||||
|
const task: DeleteMaintenanceTask =
|
||||||
|
runnable.length === 1
|
||||||
|
? runnable[0]!.task
|
||||||
|
: { kind: 'batch', tasks: runnable.map((entry) => entry.task) };
|
||||||
|
try {
|
||||||
|
await this.options.runTask(task);
|
||||||
|
for (const { request } of runnable) request.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
for (const { request } of runnable) request.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
this.scheduleDrain();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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 {
|
||||||
|
DeleteMaintenanceWorkerRuntime,
|
||||||
|
resolveDeleteMaintenanceWorkerPath,
|
||||||
|
} from './delete-maintenance-worker-runtime';
|
||||||
|
import { executeDeleteMaintenanceTask } from './delete-maintenance';
|
||||||
|
import { startSessionRecord } from './session';
|
||||||
|
import { Database } from './sqlite';
|
||||||
|
import { applyPragmas, ensureSchema, getOrCreateVideoRecord } from './storage';
|
||||||
|
|
||||||
|
type FakeWorkerListener = (value: never) => void;
|
||||||
|
|
||||||
|
function createFakeWorker() {
|
||||||
|
const listeners = new Map<string, FakeWorkerListener>();
|
||||||
|
const terminationState = { calls: 0 };
|
||||||
|
const worker = {
|
||||||
|
once(event: string, listener: FakeWorkerListener) {
|
||||||
|
listeners.set(event, listener);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
terminate: async () => {
|
||||||
|
terminationState.calls += 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { worker, listeners, terminationState };
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeWorker = ReturnType<typeof createFakeWorker>['worker'];
|
||||||
|
|
||||||
|
test('a delete batch rebuilds lifetime summaries once', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-delete-batch-test-'));
|
||||||
|
const dbPath = path.join(tempDir, 'immersion.sqlite');
|
||||||
|
let db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyPragmas(db);
|
||||||
|
ensureSchema(db);
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/batch-delete.mkv', {
|
||||||
|
canonicalTitle: 'Batch Delete',
|
||||||
|
sourcePath: '/tmp/batch-delete.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: 1,
|
||||||
|
});
|
||||||
|
const firstSessionId = startSessionRecord(db, videoId, 1_000).sessionId;
|
||||||
|
const secondSessionId = startSessionRecord(db, videoId, 2_000).sessionId;
|
||||||
|
const deletedVideoId = getOrCreateVideoRecord(db, 'local:/tmp/batch-delete-video.mkv', {
|
||||||
|
canonicalTitle: 'Batch Delete Video',
|
||||||
|
sourcePath: '/tmp/batch-delete-video.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: 1,
|
||||||
|
});
|
||||||
|
startSessionRecord(db, deletedVideoId, 3_000);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE delete_rebuild_audit (id INTEGER PRIMARY KEY);
|
||||||
|
CREATE TRIGGER count_delete_lifetime_rebuild
|
||||||
|
AFTER UPDATE OF last_rebuilt_ms ON imm_lifetime_global
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO delete_rebuild_audit (id) VALUES (NULL);
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
executeDeleteMaintenanceTask(dbPath, {
|
||||||
|
kind: 'batch',
|
||||||
|
tasks: [
|
||||||
|
{ kind: 'session', sessionId: firstSessionId },
|
||||||
|
{ kind: 'video', videoId: deletedVideoId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
db = new Database(dbPath);
|
||||||
|
const audit = db.prepare('SELECT COUNT(*) AS total FROM delete_rebuild_audit').get() as {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
const retainedSession = db
|
||||||
|
.prepare('SELECT session_id AS sessionId FROM imm_sessions WHERE video_id = ?')
|
||||||
|
.get(videoId) as { sessionId: number } | null;
|
||||||
|
const deletedVideo = db
|
||||||
|
.prepare('SELECT video_id AS videoId FROM imm_videos WHERE video_id = ?')
|
||||||
|
.get(deletedVideoId) as { videoId: number } | null;
|
||||||
|
assert.equal(retainedSession?.sessionId, secondSessionId);
|
||||||
|
assert.equal(deletedVideo, undefined);
|
||||||
|
assert.equal(
|
||||||
|
audit.total,
|
||||||
|
2,
|
||||||
|
'one rebuild performs exactly its reset and final global summary writes',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch {
|
||||||
|
// The setup connection closes before maintenance runs.
|
||||||
|
}
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'compiled delete worker removes data through its separate database connection',
|
||||||
|
{ skip: resolveDeleteMaintenanceWorkerPath() === null },
|
||||||
|
async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-delete-worker-test-'));
|
||||||
|
const dbPath = path.join(tempDir, 'immersion.sqlite');
|
||||||
|
const runtime = new DeleteMaintenanceWorkerRuntime();
|
||||||
|
let db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyPragmas(db);
|
||||||
|
ensureSchema(db);
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/worker-delete.mkv', {
|
||||||
|
canonicalTitle: 'Worker Delete',
|
||||||
|
sourcePath: '/tmp/worker-delete.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: 1,
|
||||||
|
});
|
||||||
|
const firstSessionId = startSessionRecord(db, videoId, 1_000).sessionId;
|
||||||
|
const secondSessionId = startSessionRecord(db, videoId, 2_000).sessionId;
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
await runtime.run(dbPath, {
|
||||||
|
kind: 'batch',
|
||||||
|
tasks: [
|
||||||
|
{ kind: 'session', sessionId: firstSessionId },
|
||||||
|
{ kind: 'session', sessionId: secondSessionId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
db = new Database(dbPath);
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE video_id = ?')
|
||||||
|
.get(videoId) as { total: number };
|
||||||
|
assert.equal(row.total, 0);
|
||||||
|
} finally {
|
||||||
|
runtime.destroy();
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch {
|
||||||
|
// The setup connection is already closed before the worker starts.
|
||||||
|
}
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('worker runtime warns before falling back when no emitted worker is available', async () => {
|
||||||
|
const warnings: unknown[][] = [];
|
||||||
|
const fallbackTasks: unknown[] = [];
|
||||||
|
const runtime = new DeleteMaintenanceWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => null,
|
||||||
|
warn: (...args) => warnings.push(args),
|
||||||
|
executeFallback: (_dbPath, task) => fallbackTasks.push(task),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.run('/tmp/fallback.sqlite', { kind: 'session', sessionId: 1 });
|
||||||
|
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(String(warnings[0]?.[0]), /worker unavailable/i);
|
||||||
|
assert.deepEqual(fallbackTasks, [{ kind: 'session', sessionId: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker runtime terminates a worker after successful settlement', async () => {
|
||||||
|
const { worker, listeners, terminationState } = createFakeWorker();
|
||||||
|
const runtime = new DeleteMaintenanceWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => '/tmp/delete-worker.js',
|
||||||
|
createWorker: async () => worker,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runtime.run('/tmp/test.sqlite', { kind: 'session', sessionId: 1 });
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
listeners.get('message')?.({ ok: true } as never);
|
||||||
|
await result;
|
||||||
|
|
||||||
|
assert.equal(terminationState.calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker runtime terminates a worker after failed settlement', async () => {
|
||||||
|
const { worker, listeners, terminationState } = createFakeWorker();
|
||||||
|
const runtime = new DeleteMaintenanceWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => '/tmp/delete-worker.js',
|
||||||
|
createWorker: async () => worker,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runtime.run('/tmp/test.sqlite', { kind: 'session', sessionId: 1 });
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
listeners.get('error')?.(new Error('worker failed') as never);
|
||||||
|
|
||||||
|
await assert.rejects(result, /worker failed/);
|
||||||
|
assert.equal(terminationState.calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker runtime terminates a worker created after shutdown begins', async () => {
|
||||||
|
const { worker, listeners, terminationState } = createFakeWorker();
|
||||||
|
const createGate: { resolve?: (worker: FakeWorker) => void } = {};
|
||||||
|
const fallbackTasks: unknown[] = [];
|
||||||
|
const runtime = new DeleteMaintenanceWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => '/tmp/delete-worker.js',
|
||||||
|
createWorker: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
createGate.resolve = resolve;
|
||||||
|
}),
|
||||||
|
executeFallback: (_dbPath, task) => fallbackTasks.push(task),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runtime.run('/tmp/test.sqlite', { kind: 'session', sessionId: 1 });
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
runtime.destroy();
|
||||||
|
createGate.resolve?.(worker);
|
||||||
|
|
||||||
|
await assert.rejects(result, /shut down/);
|
||||||
|
assert.equal(terminationState.calls, 1);
|
||||||
|
assert.equal(listeners.size, 0);
|
||||||
|
assert.deepEqual(fallbackTasks, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker runtime does not fall back when worker creation fails during shutdown', async () => {
|
||||||
|
const createGate: { reject?: (error: Error) => void } = {};
|
||||||
|
const fallbackTasks: unknown[] = [];
|
||||||
|
const runtime = new DeleteMaintenanceWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => '/tmp/delete-worker.js',
|
||||||
|
createWorker: () =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
createGate.reject = reject;
|
||||||
|
}),
|
||||||
|
executeFallback: (_dbPath, task) => fallbackTasks.push(task),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runtime.run('/tmp/test.sqlite', { kind: 'session', sessionId: 1 });
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
runtime.destroy();
|
||||||
|
createGate.reject?.(new Error('creation failed'));
|
||||||
|
|
||||||
|
await assert.rejects(result, /shut down/);
|
||||||
|
assert.deepEqual(fallbackTasks, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createLogger } from '../../../logger';
|
||||||
|
import { executeDeleteMaintenanceTask, type DeleteMaintenanceTask } from './delete-maintenance';
|
||||||
|
|
||||||
|
interface DeleteMaintenanceWorkerResponse {
|
||||||
|
ok?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunDeleteMaintenanceTask = (
|
||||||
|
dbPath: string,
|
||||||
|
task: DeleteMaintenanceTask,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
interface DeleteMaintenanceWorkerHandle {
|
||||||
|
once(event: 'message', listener: (message: DeleteMaintenanceWorkerResponse) => void): this;
|
||||||
|
once(event: 'error', listener: (error: Error) => void): this;
|
||||||
|
once(event: 'exit', listener: (code: number) => void): this;
|
||||||
|
terminate(): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteMaintenanceWorkerRuntimeOptions {
|
||||||
|
resolveWorkerPath?: () => string | null;
|
||||||
|
createWorker?: (
|
||||||
|
workerPath: string,
|
||||||
|
workerData: { dbPath: string; task: DeleteMaintenanceTask },
|
||||||
|
) => Promise<DeleteMaintenanceWorkerHandle>;
|
||||||
|
executeFallback?: typeof executeDeleteMaintenanceTask;
|
||||||
|
warn?: (message: string, ...meta: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDeleteMaintenanceWorkerPath(): string | null {
|
||||||
|
const workerPath = path.join(__dirname, 'delete-maintenance-worker-thread.js');
|
||||||
|
return fs.existsSync(workerPath) ? workerPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = createLogger('main:immersion-tracker:delete-worker');
|
||||||
|
|
||||||
|
export class DeleteMaintenanceWorkerRuntime {
|
||||||
|
private readonly activeWorkers = new Set<DeleteMaintenanceWorkerHandle>();
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: DeleteMaintenanceWorkerRuntimeOptions = {}) {}
|
||||||
|
|
||||||
|
async run(dbPath: string, task: DeleteMaintenanceTask): Promise<void> {
|
||||||
|
if (this.destroyed) {
|
||||||
|
throw new Error('Delete maintenance worker is shut down');
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker: DeleteMaintenanceWorkerHandle;
|
||||||
|
try {
|
||||||
|
const workerPath = (this.options.resolveWorkerPath ?? resolveDeleteMaintenanceWorkerPath)();
|
||||||
|
if (!workerPath) throw new Error('Emitted delete-maintenance worker module was not found');
|
||||||
|
const createWorker =
|
||||||
|
this.options.createWorker ??
|
||||||
|
(async (resolvedPath, workerData) => {
|
||||||
|
const { Worker } = await import('node:worker_threads');
|
||||||
|
return new Worker(resolvedPath, { workerData });
|
||||||
|
});
|
||||||
|
worker = await createWorker(workerPath, { dbPath, task });
|
||||||
|
} catch (error) {
|
||||||
|
if (this.destroyed) {
|
||||||
|
throw new Error('Delete maintenance worker is shut down');
|
||||||
|
}
|
||||||
|
(this.options.warn ?? logger.warn)(
|
||||||
|
'Delete maintenance worker unavailable; running maintenance on the current thread',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
(this.options.executeFallback ?? executeDeleteMaintenanceTask)(dbPath, task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.destroyed) {
|
||||||
|
await worker.terminate().catch(() => undefined);
|
||||||
|
throw new Error('Delete maintenance worker is shut down');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
this.activeWorkers.add(worker);
|
||||||
|
|
||||||
|
const settle = (error?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
this.activeWorkers.delete(worker);
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
void worker.terminate();
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.once('message', (message: DeleteMaintenanceWorkerResponse) => {
|
||||||
|
if (message.ok === true) {
|
||||||
|
settle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = typeof message.error === 'string' ? message.error : 'unknown worker error';
|
||||||
|
settle(new Error(`Delete maintenance failed: ${detail}`));
|
||||||
|
});
|
||||||
|
worker.once('error', (error) => settle(error));
|
||||||
|
worker.once('exit', (code) => {
|
||||||
|
settle(
|
||||||
|
new Error(
|
||||||
|
code === 0
|
||||||
|
? 'Delete maintenance worker exited without a response'
|
||||||
|
: `Delete maintenance worker exited with code ${code}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.destroyed = true;
|
||||||
|
for (const worker of this.activeWorkers) {
|
||||||
|
void worker.terminate();
|
||||||
|
}
|
||||||
|
this.activeWorkers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { parentPort, workerData } from 'node:worker_threads';
|
||||||
|
import { executeDeleteMaintenanceTask, type DeleteMaintenanceTask } from './delete-maintenance';
|
||||||
|
|
||||||
|
interface DeleteMaintenanceWorkerData {
|
||||||
|
dbPath: string;
|
||||||
|
task: DeleteMaintenanceTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentPort) {
|
||||||
|
throw new Error('delete maintenance worker missing parent port');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = parentPort;
|
||||||
|
const request = workerData as DeleteMaintenanceWorkerData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
executeDeleteMaintenanceTask(request.dbPath, request.task);
|
||||||
|
port.postMessage({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
port.postMessage({ ok: false, error: message });
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Database } from './sqlite';
|
||||||
|
import { applyPragmas } from './storage';
|
||||||
|
import { deleteAnime, deleteSession, deleteSessions, deleteVideo } from './query-maintenance';
|
||||||
|
import {
|
||||||
|
deleteMaintenanceBatch,
|
||||||
|
type DeleteMaintenanceOperation,
|
||||||
|
} from './query-delete-maintenance';
|
||||||
|
|
||||||
|
export type { DeleteMaintenanceOperation } from './query-delete-maintenance';
|
||||||
|
|
||||||
|
export type DeleteMaintenanceTask =
|
||||||
|
| DeleteMaintenanceOperation
|
||||||
|
| { kind: 'batch'; tasks: DeleteMaintenanceOperation[] };
|
||||||
|
|
||||||
|
function executeDeleteMaintenanceOperation(
|
||||||
|
db: InstanceType<typeof Database>,
|
||||||
|
task: DeleteMaintenanceOperation,
|
||||||
|
): void {
|
||||||
|
switch (task.kind) {
|
||||||
|
case 'session':
|
||||||
|
deleteSession(db, task.sessionId);
|
||||||
|
return;
|
||||||
|
case 'sessions':
|
||||||
|
deleteSessions(db, task.sessionIds);
|
||||||
|
return;
|
||||||
|
case 'video':
|
||||||
|
deleteVideo(db, task.videoId);
|
||||||
|
return;
|
||||||
|
case 'anime':
|
||||||
|
deleteAnime(db, task.animeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeDeleteMaintenanceTask(dbPath: string, task: DeleteMaintenanceTask): void {
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
try {
|
||||||
|
applyPragmas(db);
|
||||||
|
if (task.kind === 'batch') {
|
||||||
|
deleteMaintenanceBatch(db, task.tasks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeDeleteMaintenanceOperation(db, task);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { DatabaseSync } from './sqlite';
|
||||||
|
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||||
|
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||||
|
import {
|
||||||
|
applyLexicalRemovals,
|
||||||
|
cleanupUnusedCoverArtBlobHash,
|
||||||
|
deleteSessionsByIds,
|
||||||
|
forEachIdChunk,
|
||||||
|
makePlaceholders,
|
||||||
|
planLexicalRemovalsForSessions,
|
||||||
|
SQLITE_ID_CHUNK_SIZE,
|
||||||
|
type LexicalRemovalPlan,
|
||||||
|
} from './query-shared';
|
||||||
|
|
||||||
|
export type DeleteMaintenanceOperation =
|
||||||
|
| { kind: 'session'; sessionId: number }
|
||||||
|
| { kind: 'sessions'; sessionIds: number[] }
|
||||||
|
| { kind: 'video'; videoId: number }
|
||||||
|
| { kind: 'anime'; animeId: number };
|
||||||
|
|
||||||
|
function addOperationTargets(
|
||||||
|
operations: DeleteMaintenanceOperation[],
|
||||||
|
sessionIds: Set<number>,
|
||||||
|
videoIds: Set<number>,
|
||||||
|
animeIds: Set<number>,
|
||||||
|
): void {
|
||||||
|
for (const operation of operations) {
|
||||||
|
switch (operation.kind) {
|
||||||
|
case 'session':
|
||||||
|
sessionIds.add(operation.sessionId);
|
||||||
|
break;
|
||||||
|
case 'sessions':
|
||||||
|
for (const sessionId of operation.sessionIds) sessionIds.add(sessionId);
|
||||||
|
break;
|
||||||
|
case 'video':
|
||||||
|
videoIds.add(operation.videoId);
|
||||||
|
break;
|
||||||
|
case 'anime':
|
||||||
|
animeIds.add(operation.animeId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIds(
|
||||||
|
db: DatabaseSync,
|
||||||
|
buildSql: (placeholders: string) => string,
|
||||||
|
params: number[],
|
||||||
|
column: string,
|
||||||
|
): number[] {
|
||||||
|
if (params.length === 0) return [];
|
||||||
|
const ids: number[] = [];
|
||||||
|
forEachIdChunk(params, (chunk) => {
|
||||||
|
const rows = db.prepare(buildSql(makePlaceholders(chunk))).all(...chunk) as Array<
|
||||||
|
Record<string, number>
|
||||||
|
>;
|
||||||
|
for (const row of rows) ids.push(row[column]!);
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planLexicalRemovalsInChunks(db: DatabaseSync, sessionIds: number[]): LexicalRemovalPlan {
|
||||||
|
const combined: LexicalRemovalPlan = { words: [], kanji: [] };
|
||||||
|
const merge = (target: LexicalRemovalPlan['words'], source: LexicalRemovalPlan['words']) => {
|
||||||
|
const byId = new Map(target.map((entry) => [entry.id, entry]));
|
||||||
|
for (const entry of source) {
|
||||||
|
const existing = byId.get(entry.id);
|
||||||
|
if (!existing) {
|
||||||
|
const added = { ...entry };
|
||||||
|
target.push(added);
|
||||||
|
byId.set(entry.id, added);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.removedFrequency += entry.removedFrequency;
|
||||||
|
if (
|
||||||
|
entry.removedFirstSeenMs !== null &&
|
||||||
|
(existing.removedFirstSeenMs === null ||
|
||||||
|
entry.removedFirstSeenMs < existing.removedFirstSeenMs)
|
||||||
|
) {
|
||||||
|
existing.removedFirstSeenMs = entry.removedFirstSeenMs;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.removedLastSeenMs !== null &&
|
||||||
|
(existing.removedLastSeenMs === null ||
|
||||||
|
entry.removedLastSeenMs > existing.removedLastSeenMs)
|
||||||
|
) {
|
||||||
|
existing.removedLastSeenMs = entry.removedLastSeenMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
forEachIdChunk(sessionIds, (chunk) => {
|
||||||
|
const plan = planLexicalRemovalsForSessions(db, chunk);
|
||||||
|
merge(combined.words, plan.words);
|
||||||
|
merge(combined.kanji, plan.kanji);
|
||||||
|
});
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMaintenanceBatch(
|
||||||
|
db: DatabaseSync,
|
||||||
|
operations: DeleteMaintenanceOperation[],
|
||||||
|
): void {
|
||||||
|
if (operations.length === 0) return;
|
||||||
|
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const sessionIds = new Set<number>();
|
||||||
|
const videoIds = new Set<number>();
|
||||||
|
const animeIds = new Set<number>();
|
||||||
|
addOperationTargets(operations, sessionIds, videoIds, animeIds);
|
||||||
|
|
||||||
|
const animeIdList = [...animeIds];
|
||||||
|
for (const videoId of selectIds(
|
||||||
|
db,
|
||||||
|
(placeholders) => `SELECT video_id FROM imm_videos WHERE anime_id IN (${placeholders})`,
|
||||||
|
animeIdList,
|
||||||
|
'video_id',
|
||||||
|
)) {
|
||||||
|
videoIds.add(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoIdList = [...videoIds];
|
||||||
|
for (const sessionId of selectIds(
|
||||||
|
db,
|
||||||
|
(placeholders) => `SELECT session_id FROM imm_sessions WHERE video_id IN (${placeholders})`,
|
||||||
|
videoIdList,
|
||||||
|
'session_id',
|
||||||
|
)) {
|
||||||
|
sessionIds.add(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIdList = [...sessionIds];
|
||||||
|
const lexicalRemovals = planLexicalRemovalsInChunks(db, sessionIdList);
|
||||||
|
const affectedRollupGroups = sessionIdList
|
||||||
|
.flatMap((_, index) =>
|
||||||
|
index % SQLITE_ID_CHUNK_SIZE === 0
|
||||||
|
? getRollupGroupsForSessions(db, sessionIdList.slice(index, index + SQLITE_ID_CHUNK_SIZE))
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.filter((group) => !videoIds.has(group.videoId));
|
||||||
|
const coverBlobHashes = new Set<string>();
|
||||||
|
if (videoIdList.length > 0) {
|
||||||
|
forEachIdChunk(videoIdList, (chunk) => {
|
||||||
|
const placeholders = makePlaceholders(chunk);
|
||||||
|
const artRows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT cover_blob_hash AS coverBlobHash
|
||||||
|
FROM imm_media_art
|
||||||
|
WHERE video_id IN (${placeholders}) AND cover_blob_hash IS NOT NULL`,
|
||||||
|
)
|
||||||
|
.all(...chunk) as Array<{ coverBlobHash: string }>;
|
||||||
|
for (const row of artRows) coverBlobHashes.add(row.coverBlobHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteSessionsByIds(db, sessionIdList);
|
||||||
|
forEachIdChunk(videoIdList, (chunk) => {
|
||||||
|
const placeholders = makePlaceholders(chunk);
|
||||||
|
db.prepare(`DELETE FROM imm_subtitle_lines WHERE video_id IN (${placeholders})`).run(
|
||||||
|
...chunk,
|
||||||
|
);
|
||||||
|
db.prepare(`DELETE FROM imm_daily_rollups WHERE video_id IN (${placeholders})`).run(
|
||||||
|
...chunk,
|
||||||
|
);
|
||||||
|
db.prepare(`DELETE FROM imm_monthly_rollups WHERE video_id IN (${placeholders})`).run(
|
||||||
|
...chunk,
|
||||||
|
);
|
||||||
|
db.prepare(`DELETE FROM imm_media_art WHERE video_id IN (${placeholders})`).run(...chunk);
|
||||||
|
db.prepare(`DELETE FROM imm_videos WHERE video_id IN (${placeholders})`).run(...chunk);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deleteSessionsByIds(db, sessionIdList);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const coverBlobHash of coverBlobHashes) {
|
||||||
|
cleanupUnusedCoverArtBlobHash(db, coverBlobHash);
|
||||||
|
}
|
||||||
|
if (animeIdList.length > 0) {
|
||||||
|
forEachIdChunk(animeIdList, (chunk) => {
|
||||||
|
const placeholders = makePlaceholders(chunk);
|
||||||
|
db.prepare(`DELETE FROM imm_lifetime_anime WHERE anime_id IN (${placeholders})`).run(
|
||||||
|
...chunk,
|
||||||
|
);
|
||||||
|
db.prepare(`DELETE FROM imm_anime WHERE anime_id IN (${placeholders})`).run(...chunk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLexicalRemovals(db, lexicalRemovals);
|
||||||
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,18 +115,29 @@ export function getVocabularyStats(
|
|||||||
const whereClause = hasExclude
|
const whereClause = hasExclude
|
||||||
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
|
? `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(`
|
const stmt = db.prepare(`
|
||||||
SELECT w.id AS wordId, w.headword, w.word, w.reading,
|
WITH page AS (
|
||||||
w.part_of_speech AS partOfSpeech, w.pos1, w.pos2, w.pos3,
|
SELECT id, headword, word, reading, part_of_speech, pos1, pos2, pos3,
|
||||||
w.frequency, w.frequency_rank AS frequencyRank,
|
frequency, frequency_rank, first_seen, last_seen
|
||||||
w.first_seen AS firstSeen, w.last_seen AS lastSeen,
|
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
|
COUNT(DISTINCT sl.anime_id) AS animeCount
|
||||||
FROM imm_words w
|
FROM page p
|
||||||
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
|
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
|
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 p.id
|
||||||
GROUP BY w.id
|
ORDER BY p.frequency DESC, p.id
|
||||||
ORDER BY w.frequency DESC LIMIT ? OFFSET ?
|
|
||||||
`);
|
`);
|
||||||
const visibleRows: VocabularyStatsRow[] = [];
|
const visibleRows: VocabularyStatsRow[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import { PartOfSpeech, type MergedToken } from '../../../types';
|
|||||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||||
import {
|
import {
|
||||||
|
applyLexicalRemovals,
|
||||||
cleanupUnusedCoverArtBlobHash,
|
cleanupUnusedCoverArtBlobHash,
|
||||||
deleteSessionsByIds,
|
deleteSessionsByIds,
|
||||||
findSharedCoverBlobHash,
|
findSharedCoverBlobHash,
|
||||||
getAffectedKanjiIdsForSessions,
|
planLexicalRemovalsForSessions,
|
||||||
getAffectedKanjiIdsForVideo,
|
planLexicalRemovalsForVideos,
|
||||||
getAffectedWordIdsForSessions,
|
|
||||||
getAffectedWordIdsForVideo,
|
|
||||||
refreshLexicalAggregates,
|
|
||||||
toDbMs,
|
toDbMs,
|
||||||
toDbTimestamp,
|
toDbTimestamp,
|
||||||
} from './query-shared';
|
} from './query-shared';
|
||||||
@@ -232,12 +230,13 @@ export async function cleanupVocabularyStats(
|
|||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
);
|
);
|
||||||
const moveOccurrencesStmt = db.prepare(
|
const moveOccurrencesStmt = db.prepare(
|
||||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
|
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms)
|
||||||
SELECT line_id, ?, occurrence_count
|
SELECT line_id, ?, occurrence_count, seen_ms
|
||||||
FROM imm_word_line_occurrences
|
FROM imm_word_line_occurrences
|
||||||
WHERE word_id = ?
|
WHERE word_id = ?
|
||||||
ON CONFLICT(line_id, word_id) DO UPDATE SET
|
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(
|
const deleteOccurrencesStmt = db.prepare(
|
||||||
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
|
'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 {
|
export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||||
const sessionIds = [sessionId];
|
const sessionIds = [sessionId];
|
||||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
|
||||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
db.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
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);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
applyLexicalRemovals(db, lexicalRemovals);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
@@ -503,14 +504,13 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
|||||||
|
|
||||||
export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
||||||
if (sessionIds.length === 0) return;
|
if (sessionIds.length === 0) return;
|
||||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
|
||||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
db.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
|
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
|
||||||
|
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
applyLexicalRemovals(db, lexicalRemovals);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
@@ -520,24 +520,83 @@ 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 {
|
export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||||
const artRow = db
|
db.exec('BEGIN IMMEDIATE');
|
||||||
.prepare(
|
try {
|
||||||
`
|
const artRow = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
SELECT cover_blob_hash AS coverBlobHash
|
SELECT cover_blob_hash AS coverBlobHash
|
||||||
FROM imm_media_art
|
FROM imm_media_art
|
||||||
WHERE video_id = ?
|
WHERE video_id = ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(videoId) as { coverBlobHash: string | null } | undefined;
|
.get(videoId) as { coverBlobHash: string | null } | undefined;
|
||||||
const affectedWordIds = getAffectedWordIdsForVideo(db, videoId);
|
const lexicalRemovals = planLexicalRemovalsForVideos(db, [videoId]);
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForVideo(db, videoId);
|
const sessions = db
|
||||||
const sessions = db
|
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
|
||||||
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
|
.all(videoId) as Array<{ session_id: number }>;
|
||||||
.all(videoId) as Array<{ session_id: number }>;
|
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
|
||||||
try {
|
|
||||||
deleteSessionsByIds(
|
deleteSessionsByIds(
|
||||||
db,
|
db,
|
||||||
sessions.map((session) => session.session_id),
|
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);
|
db.prepare('DELETE FROM imm_media_art WHERE video_id = ?').run(videoId);
|
||||||
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
||||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
applyLexicalRemovals(db, lexicalRemovals);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export function makePlaceholders(values: number[]): string {
|
|||||||
return values.map(() => '?').join(',');
|
return values.map(() => '?').join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SQLITE_ID_CHUNK_SIZE = 1_000;
|
||||||
|
|
||||||
|
export function forEachIdChunk(ids: number[], callback: (chunk: number[]) => void): void {
|
||||||
|
for (let start = 0; start < ids.length; start += SQLITE_ID_CHUNK_SIZE) {
|
||||||
|
callback(ids.slice(start, start + SQLITE_ID_CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvedCoverBlobExpr(mediaAlias: string, blobStoreAlias: string): string {
|
export function resolvedCoverBlobExpr(mediaAlias: string, blobStoreAlias: string): string {
|
||||||
return `COALESCE(${blobStoreAlias}.cover_blob, CASE WHEN ${mediaAlias}.cover_blob_hash IS NULL THEN ${mediaAlias}.cover_blob ELSE NULL END)`;
|
return `COALESCE(${blobStoreAlias}.cover_blob, CASE WHEN ${mediaAlias}.cover_blob_hash IS NULL THEN ${mediaAlias}.cover_blob ELSE NULL END)`;
|
||||||
}
|
}
|
||||||
@@ -203,6 +211,167 @@ export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number):
|
|||||||
return getAffectedIdsForVideo(db, 'kanji', videoId);
|
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 {
|
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||||
if (wordIds.length === 0) {
|
if (wordIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -214,11 +383,18 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
|||||||
SELECT
|
SELECT
|
||||||
w.id AS wordId,
|
w.id AS wordId,
|
||||||
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
|
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
|
||||||
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
|
MIN(COALESCE(o.seen_ms, (
|
||||||
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
|
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
|
FROM imm_words w
|
||||||
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
|
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)})
|
WHERE w.id IN (${makePlaceholders(wordIds)})
|
||||||
GROUP BY w.id
|
GROUP BY w.id
|
||||||
`,
|
`,
|
||||||
@@ -263,11 +439,18 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
|||||||
SELECT
|
SELECT
|
||||||
k.id AS kanjiId,
|
k.id AS kanjiId,
|
||||||
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
|
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
|
||||||
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
|
MIN(COALESCE(o.seen_ms, (
|
||||||
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
|
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
|
FROM imm_kanji k
|
||||||
LEFT JOIN imm_kanji_line_occurrences o ON o.kanji_id = k.id
|
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)})
|
WHERE k.id IN (${makePlaceholders(kanjiIds)})
|
||||||
GROUP BY k.id
|
GROUP BY k.id
|
||||||
`,
|
`,
|
||||||
@@ -315,17 +498,19 @@ export function deleteSessionsByIds(db: DatabaseSync, sessionIds: number[]): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholders = makePlaceholders(sessionIds);
|
forEachIdChunk(sessionIds, (chunk) => {
|
||||||
db.prepare(`DELETE FROM imm_subtitle_lines WHERE session_id IN (${placeholders})`).run(
|
const placeholders = makePlaceholders(chunk);
|
||||||
...sessionIds,
|
db.prepare(`DELETE FROM imm_subtitle_lines WHERE session_id IN (${placeholders})`).run(
|
||||||
);
|
...chunk,
|
||||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE session_id IN (${placeholders})`).run(
|
);
|
||||||
...sessionIds,
|
db.prepare(`DELETE FROM imm_session_telemetry WHERE session_id IN (${placeholders})`).run(
|
||||||
);
|
...chunk,
|
||||||
db.prepare(`DELETE FROM imm_session_events WHERE session_id IN (${placeholders})`).run(
|
);
|
||||||
...sessionIds,
|
db.prepare(`DELETE FROM imm_session_events WHERE session_id IN (${placeholders})`).run(
|
||||||
);
|
...chunk,
|
||||||
db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
);
|
||||||
|
db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...chunk);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toDbMs(ms: number | bigint): bigint {
|
export function toDbMs(ms: number | bigint): bigint {
|
||||||
|
|||||||
@@ -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 {
|
function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: string): void {
|
||||||
if (hasColumn(db, tableName, columnName)) {
|
if (hasColumn(db, tableName, columnName)) {
|
||||||
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||||
@@ -923,6 +950,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
line_id INTEGER NOT NULL,
|
line_id INTEGER NOT NULL,
|
||||||
word_id INTEGER NOT NULL,
|
word_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, word_id),
|
PRIMARY KEY(line_id, word_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(word_id) REFERENCES imm_words(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,
|
line_id INTEGER NOT NULL,
|
||||||
kanji_id INTEGER NOT NULL,
|
kanji_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, kanji_id),
|
PRIMARY KEY(line_id, kanji_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(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,
|
line_id INTEGER NOT NULL,
|
||||||
word_id INTEGER NOT NULL,
|
word_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, word_id),
|
PRIMARY KEY(line_id, word_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(word_id) REFERENCES imm_words(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,
|
line_id INTEGER NOT NULL,
|
||||||
kanji_id INTEGER NOT NULL,
|
kanji_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, kanji_id),
|
PRIMARY KEY(line_id, kanji_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(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
|
CREATE INDEX IF NOT EXISTS idx_subtitle_lines_anime_line
|
||||||
ON imm_subtitle_lines(anime_id, line_index)
|
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(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word
|
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word_seen
|
||||||
ON imm_word_line_occurrences(word_id, line_id)
|
ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id)
|
||||||
`);
|
`);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji
|
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji_seen
|
||||||
ON imm_kanji_line_occurrences(kanji_id, line_id)
|
ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id)
|
||||||
`);
|
`);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_art_cover_blob_hash
|
CREATE INDEX IF NOT EXISTS idx_media_art_cover_blob_hash
|
||||||
@@ -1475,21 +1512,23 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
|||||||
`),
|
`),
|
||||||
wordLineOccurrenceUpsertStmt: db.prepare(`
|
wordLineOccurrenceUpsertStmt: db.prepare(`
|
||||||
INSERT INTO imm_word_line_occurrences (
|
INSERT INTO imm_word_line_occurrences (
|
||||||
line_id, word_id, occurrence_count
|
line_id, word_id, occurrence_count, seen_ms
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?
|
?, ?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT(line_id, word_id) DO UPDATE SET
|
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(`
|
kanjiLineOccurrenceUpsertStmt: db.prepare(`
|
||||||
INSERT INTO imm_kanji_line_occurrences (
|
INSERT INTO imm_kanji_line_occurrences (
|
||||||
line_id, kanji_id, occurrence_count
|
line_id, kanji_id, occurrence_count, seen_ms
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?
|
?, ?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
|
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(`
|
videoAnimeIdSelectStmt: db.prepare(`
|
||||||
SELECT anime_id FROM imm_videos
|
SELECT anime_id FROM imm_videos
|
||||||
@@ -1630,11 +1669,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
|||||||
const lineId = Number(lineResult.lastInsertRowid);
|
const lineId = Number(lineResult.lastInsertRowid);
|
||||||
for (const occurrence of write.wordOccurrences) {
|
for (const occurrence of write.wordOccurrences) {
|
||||||
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
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) {
|
for (const occurrence of write.kanjiOccurrences) {
|
||||||
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const SCHEMA_VERSION = 18;
|
export const SCHEMA_VERSION = 19;
|
||||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||||
export const DEFAULT_BATCH_SIZE = 25;
|
export const DEFAULT_BATCH_SIZE = 25;
|
||||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export { Texthooker } from './texthooker';
|
export { Texthooker } from './texthooker';
|
||||||
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
|
export {
|
||||||
|
hasMpvWebsocketPlugin,
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
|
SubtitleWebSocket,
|
||||||
|
} from './subtitle-ws';
|
||||||
export { registerGlobalShortcuts } from './shortcut';
|
export { registerGlobalShortcuts } from './shortcut';
|
||||||
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
||||||
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
|
ChangelogSnapshot,
|
||||||
CompiledSessionBinding,
|
CompiledSessionBinding,
|
||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
PlaylistBrowserMutationResult,
|
PlaylistBrowserMutationResult,
|
||||||
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
|
|||||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
entries: [],
|
entries: [],
|
||||||
})),
|
})),
|
||||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||||
|
getChangelogSnapshot: options.getChangelogSnapshot,
|
||||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||||
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||||
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.appendClipboardVideoToQueue();
|
return deps.appendClipboardVideoToQueue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => {
|
||||||
|
const refresh =
|
||||||
|
typeof payload === 'object' && payload !== null && 'refresh' in payload
|
||||||
|
? (payload as { refresh?: unknown }).refresh === true
|
||||||
|
: false;
|
||||||
|
if (!deps.getChangelogSnapshot) {
|
||||||
|
throw new Error('Changelog service is unavailable.');
|
||||||
|
}
|
||||||
|
return await deps.getChangelogSnapshot({ refresh });
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
||||||
return await deps.getPlaylistBrowserSnapshot();
|
return await deps.getPlaylistBrowserSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
|
|||||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime enables quiet background mode by default', () => {
|
test('runStartupBootstrapRuntime lets config govern background log level by default', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const args = makeArgs({ background: true });
|
const args = makeArgs({ background: true });
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.backgroundMode, true);
|
assert.equal(result.backgroundMode, true);
|
||||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function runStartupBootstrapRuntime(
|
|||||||
|
|
||||||
if (initialArgs.logLevel) {
|
if (initialArgs.logLevel) {
|
||||||
deps.setLogLevel(initialArgs.logLevel, 'cli');
|
deps.setLogLevel(initialArgs.logLevel, 'cli');
|
||||||
} else if (initialArgs.background || initialArgs.update) {
|
} else if (initialArgs.update) {
|
||||||
deps.setLogLevel('warn', 'cli');
|
deps.setLogLevel('warn', 'cli');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||||
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
||||||
@@ -74,6 +75,31 @@ function detectImageContentType(bytes: Uint8Array): string {
|
|||||||
return 'application/octet-stream';
|
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 {
|
function createLimitedTaskRunner(maxConcurrentTasks: number): (task: () => Promise<void>) => void {
|
||||||
const queue: Array<() => Promise<void>> = [];
|
const queue: Array<() => Promise<void>> = [];
|
||||||
let activeTasks = 0;
|
let activeTasks = 0;
|
||||||
@@ -137,13 +163,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
|||||||
art = await tracker.getAnimeCoverArt(animeId);
|
art = await tracker.getAnimeCoverArt(animeId);
|
||||||
}
|
}
|
||||||
if (!art?.coverBlob) return c.body(null, 404);
|
if (!art?.coverBlob) return c.body(null, 404);
|
||||||
const bytes = new Uint8Array(art.coverBlob);
|
return coverResponse(new Uint8Array(art.coverBlob), c.req.header('if-none-match'));
|
||||||
return new Response(bytes, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': detectImageContentType(bytes),
|
|
||||||
'Cache-Control': 'public, max-age=86400',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media/:videoId/cover', async (c) => {
|
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);
|
art = await tracker.getCoverArt(videoId);
|
||||||
}
|
}
|
||||||
if (!art?.coverBlob) return c.body(null, 404);
|
if (!art?.coverBlob) return c.body(null, 404);
|
||||||
const bytes = new Uint8Array(art.coverBlob);
|
return coverResponse(new Uint8Array(art.coverBlob), c.req.header('if-none-match'));
|
||||||
return new Response(bytes, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': detectImageContentType(bytes),
|
|
||||||
'Cache-Control': 'public, max-age=604800',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user