Compare commits

..

32 Commits

Author SHA1 Message Date
sudacode 18790481ff fix(stats): preserve manual episode assignments across reparsing 2026-08-14 01:51:35 -07:00
sudacode 8b71b38a26 test(stats): seed lifetime media rows in legacy redistribution fixture
The reassignAnimeAnilist redistribution test seeded raw sessions and
telemetry but not the imm_lifetime_media rows a finalized session leaves
behind. The old destructive lifetime rebuild re-derived summaries from
raw sessions and hid that gap; the non-destructive recompute reads
per-video lifetime rows, so the fixture must contain them.
2026-08-14 00:26:07 -07:00
sudacode 6311838ef6 fix(stats): preserve lifetime history across anime merges and moves
- Recompute anime aggregates from retained lifetime media summaries
- Prevent sequel title evidence and AniList conflicts from misattributing entries
2026-08-14 00:17:14 -07:00
sudacode 93bdff1ca2 fix(stats): fail closed when queued writes cannot drain
- Guard delete maintenance and AniList reassignment
- Add regression coverage for undrained write queues
2026-08-13 23:32:40 -07:00
sudacode 23e382dff1 fix(stats): preserve season boundaries during AniList merge resolution
- Allow manual cross-season reassignment without merging entries
- Require validated title matches for automatic merges
- Share title normalization across AniList and stats flows
2026-08-13 23:12:08 -07:00
sudacode 528cc798ec fix(stats): review fuzzy AniList duplicates before merging
- Preserve merged title aliases for future episodes
- Fail closed when queued writes cannot drain
2026-08-13 23:12:08 -07:00
sudacode 874fb22dc3 test(stats): assert lifetime rebuild reflects drained telemetry
- queue a telemetry write behind subtitle lines so it lands past the first batch
- assert imm_lifetime_anime picks up linesSeen/activeMs/cards only after the full queue drains
2026-08-13 23:10:15 -07:00
sudacode 8af4570c07 test(stats): move write-queue drain test to its own file
- Extract the mergeAnime/moveVideoToAnime queue-drain test into immersion-tracker-write-queue.test.ts
- Factor shared setup (seedTwoEntries, queueSubtitleLines, countLinesForAnime) into helpers and add a moveVideoToAnime coverage case
2026-08-13 23:10:15 -07:00
sudacode 2ea95c7b13 fix(stats): drain full write queue before anime merge/move rebuilds
- Replace single flushNow() with drainWriteQueue loop so forced telemetry appended after a full batch isn't left unwritten before merge/move/rebuild summaries recompute
- Add dialog a11y to AnimeMergeDialog/LibraryEntryPicker: aria-modal, labelled headings, alert roles for errors, labelled search input, close button labels
2026-08-13 23:10:15 -07:00
sudacode 5abc3b84f4 fix(stats): fix data loss and error handling in anime merge/move
- flush pending telemetry before merge/move so in-progress session time isn't dropped from lifetime totals
- repoint subtitle lines by video_id instead of anime_id so lines recorded before the async title parse assigns a link aren't stranded
- stop absorbing metadata into the target when moving an episode out of an emptied entry (a move isn't a same-show claim)
- return 404 only for missing episode/target, not storage failures; return 404 when a merge folds nothing
- keep AnimeMergeDialog open while a merge is in flight instead of letting dismiss race the request
- surface library load failures in LibraryEntryPicker instead of showing an empty list
2026-08-13 23:10:15 -07:00
sudacode 9c503fc67d feat(stats): add library entry merge and episode move
- Add multi-select "Merge Selected" flow to fold duplicate library cards into one, preserving sessions, mined cards, and watch time
- Add per-episode "move to another entry" action for reassigning stray episodes, pruning the source entry when emptied
- Auto-merge library entries that resolve to the same AniList id when their parsed seasons are compatible
- Add mergeAnime/moveVideoToAnime service methods, stats-server routes, and HTTP contract types
2026-08-13 23:10:15 -07:00
sudacode 47b5903392 chore(release): prepare v0.19.3 2026-08-13 23:02:57 -07:00
sudacode bf85554d1e fix(stats): batch deletes off the main thread (#194) 2026-08-13 22:31:52 -07:00
sudacode d74c7e1235 chore: add Claude instructions symlink and update js-yaml 2026-08-11 22:31:26 -07:00
sudacode 57ddd19953 fix(overlay): handle X11 display scaling across monitors (#193) 2026-08-11 22:24:01 -07:00
sudacode ee25536d90 fix(dictionary): stop large character dictionaries from timing out (#189) 2026-08-11 18:40:18 -07:00
sudacode 7b0fbdf254 fix(subtitles): collapse duplicate ASS events and decode text once (#186) 2026-08-10 22:21:44 -07:00
sudacode 2fefc83e3f fix(playback): stop forcing legacy OpenGL renderer on X11 mpv backend (#188) 2026-08-06 23:52:35 -07:00
sudacode dbdf578c68 perf(tokenizer): single-pass Yomitan scan with cross-line caching and prefetch fixes (#185) 2026-08-06 21:44:09 -07:00
sudacode 441ecf3c04 feat(overlay): add in-app changelog modal (#187) 2026-08-05 22:19:13 -07:00
sudacode a0dde4ee3e chore(release): v0.19.2 2026-08-04 18:51:15 -07:00
sudacode fe4dacc1e7 fix(overlay): show plain subtitle line immediately on tokenization cache miss (#184) 2026-08-04 01:55:52 -07:00
sudacode b08cd0db35 fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183) 2026-08-03 21:22:18 -07:00
sudacode bffb1c5982 fix(logging): surface subtitle processing debug/warn logs (#182) 2026-08-03 20:44:39 -07:00
sudacode 5b8848518a feat(subsync): add reference and target subtitle track picker (#181) 2026-08-03 01:00:14 -07:00
sudacode 176edd67f1 chore(release): v0.19.1 2026-08-01 23:59:20 -07:00
sudacode 4d65dec340 fix(youtube): prevent playlist URLs from stalling yt-dlp probes (#180) 2026-08-01 22:56:28 -07:00
sudacode 6607c333bc fix(overlay): strip spinner frame from subsync overlay card
- Add overlayBody override to ConfiguredStatusNotificationOptions so overlay/OSD/desktop can diverge
- Extract getSubsyncStatusNotificationOptions() to strip the ASCII spinner frame from the overlay card (OSD keeps it since it renders the raw spinner)
- Add tests for spinner stripping and subsync result notifications
2026-07-31 18:03:26 -07:00
sudacode b2bbf1ae12 chore: regenerate config example artifacts 2026-07-31 17:49:02 -07:00
sudacode b204d4dd6e feat(anki): add configurable word card type for Kiku/Lapis (#175) 2026-07-31 17:17:29 -07:00
sudacode 89ed675935 fix(overlay): keep Yomitan popup interactive on macOS/Windows (#177) 2026-07-30 19:47:08 -07:00
sudacode 1995200e76 chore(release): prepare v0.19.0 2026-07-29 22:26:03 -07:00
278 changed files with 19590 additions and 2948 deletions
+87
View File
@@ -1,5 +1,92 @@
# Changelog
## v0.19.3 (2026-08-13)
### Added
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
### Changed
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
### Fixed
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
<details>
<summary>Internal changes</summary>
### Internal
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
</details>
## 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)
### Added
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+9 -11
View File
@@ -26,7 +26,7 @@
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.28.0",
"undici": "7.29.0",
},
},
},
@@ -36,16 +36,18 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.29.0",
},
"packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -266,7 +268,7 @@
"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=="],
@@ -404,7 +406,7 @@
"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=="],
@@ -496,7 +498,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -714,7 +716,7 @@
"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=="],
@@ -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/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=="],
"@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/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=="],
"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.
-5
View File
@@ -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.
@@ -1,4 +0,0 @@
type: changed
area: shortcuts
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
-8
View File
@@ -1,8 +0,0 @@
type: fixed
area: anilist
- Season 2 and later files now resolve to the right AniList entry. AniList has no numbered seasons (sequels are separate entries titled `Zoku`, `Kan`, `2nd Season`), so the old `<title> Season N` search returned nothing and silently fell back to the season 1 entry: an Oregairu S03 episode loaded the season 1 character dictionary and pushed watch progress onto season 1. SubMiner now searches the base title and walks AniList `SEQUEL` relations to reach the requested season, ordering the franchise's TV entries by air date when the relation chain is incomplete.
- When the season still cannot be located, SubMiner no longer writes progress to the season 1 entry; it skips the update and points at the manual AniList override instead. The character dictionary logs the same warning and states which entry it fell back to.
- Character dictionary overrides are now scoped by detected season as well as directory, so seasons kept in one flat folder no longer share (or overwrite) each other's override, and an override now also pins the entry used for AniList watch progress: correcting a wrong match once fixes both the dictionary and progress tracking.
- Cover art lookup uses the same season-aware resolution, so per-season art no longer falls back to the season 1 cover.
- An unresolvable season is no longer cached as if it were a normal match, so the fallback cannot silently stick for every later episode of that series.
-4
View File
@@ -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,4 +0,0 @@
type: fixed
area: stats
- Relinking a title to a different AniList entry now updates its cover in the stats Library grid too. The detail view picked up the new art immediately while the grid kept the previous entry's image, so several unrelated titles could end up sharing one wrong cover. The library list is refetched after a relink, and cover responses now carry an ETag and revalidate instead of being cached for a day, so a stale image can no longer be served from the browser cache.
-7
View File
@@ -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).
-5
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: stats
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Compatible local episodes in the same directory reuse a uniquely corrected destination, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: overlay
- Consolidated renderer modal state handling into a descriptor registry.
-4
View File
@@ -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.
-4
View File
@@ -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.
-5
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
type: added
area: stats
- Added a "Delete Entry" action to the stats Library detail view. It removes the whole title in one step — every episode, session, subtitle line, rollup, cover art and vocabulary counts derived from them — and the title disappears from the Library grid. Previously only individual episodes and sessions could be deleted, so a mistakenly created entry had to be cleared episode by episode and still lingered in the library.
- Deletion progress is now shown app-wide instead of only on Overview and Sessions. Every delete (session, session group, episode, and library entry) drives a sweeping bar at the top of the window plus a bottom-right status toast, and both stay visible across tab switches, detail views, and the in-app stats overlay window. The per-tab indicators previously went blank the moment you left the tab that started the delete.
-7
View File
@@ -1,7 +0,0 @@
type: fixed
area: stats
- Made deleting stats data much faster and stopped it stalling playback. Every delete used to recompute each affected word's totals by re-reading that word's whole occurrence history across the library, which meant one random read into the largest table per occurrence. On a 960-session library that cost 3.8s to delete a single session and over a minute to clear a 12-episode title, and because the stats server runs in the app process when the app owns it, that time was spent blocking mpv.
- Word and kanji occurrences now store the subtitle line's timestamp alongside the count, so those totals are answered from a covering index instead of the subtitle-line table, and a delete only subtracts what it actually removed rather than recomputing untouched entries. Measured on the same 960-session library: deleting one session 3832ms to 190ms, a ten-session day group 6453ms to 433ms, one episode 5437ms to 263ms, and a whole 12-episode title 60556ms to 621ms.
- Opening the Vocabulary tab no longer stalls either. It was computing "seen in N titles" for every word in the library before ordering by frequency and keeping the top 100, so the whole occurrence history was walked to produce one page. It now picks the page first and counts only those rows: 2136ms to 62ms on the same library.
- The first launch after upgrading migrates the existing stats database in place (about 3s for a 288k-line library) and it grows roughly 20% from the added index. Vocabulary dates recorded before the upgrade keep working while the migration runs.
-8
View File
@@ -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).
-4
View File
@@ -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.
-4
View File
@@ -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.
+5 -2
View File
@@ -523,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// 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.
// 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
"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
} // 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.
// ==========================================
+16 -1
View File
@@ -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 (19) 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)
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.
@@ -314,7 +329,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
### What Gets Merged
| Field | Merge behavior |
| -------- | ---------------------------------------- |
| -------- | --------------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
+2 -2
View File
@@ -75,8 +75,8 @@ src/
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse/gamepad interaction modules
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
# character dictionary, playlist browser, subtitle sidebar,
# YouTube track picker, controller config/debug/select)
# changelog, character dictionary, playlist browser, subtitle
# sidebar, YouTube track picker, controller config/debug/select)
positioning/ # Subtitle position controller (drag-to-reposition)
settings/ # Settings window UI (model, controls, markup)
types/ # Domain type modules (anki, config, integrations, ...)
+94 -2
View File
@@ -1,6 +1,98 @@
# Changelog
## v0.18.0 (2026-07-10)
## v0.19.3 (2026-08-13)
**Added**
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
**Changed**
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
**Fixed**
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
<details>
<summary>Internal changes</summary>
**Internal**
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
</details>
## 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**
- 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 +123,7 @@
</details>
## Previous Versions
</details>
<details>
<summary>v0.17.x</summary>
+25 -7
View File
@@ -399,7 +399,7 @@ See `config.example.jsonc` for detailed configuration options.
```
| Option | Values | Description |
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
@@ -556,7 +556,7 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
"enabled": true,
"fieldGrouping": "manual",
"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.
- 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).
- `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
@@ -1168,7 +1186,7 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
```
| Option | Values | Description |
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
@@ -1178,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
### 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
```json
@@ -1229,7 +1247,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
```
| Option | Values | Description |
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
@@ -1540,7 +1558,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
```
| Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
+7
View File
@@ -57,6 +57,13 @@ 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.
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
![Stats Library](/screenshots/stats-library.png)
+6 -3
View File
@@ -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.
2. Select the sync engine (alass or ffsubsync).
3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle.
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. 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.
+5 -2
View File
@@ -523,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// 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.
// 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
"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
} // 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.
// ==========================================
+5 -4
View File
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
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).
- 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).
@@ -405,8 +405,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
SubMiner handles this automatically:
- 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.
- It launches its own window under XWayland (it sets `--ozone-platform=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=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
- Fractional and mixed-monitor display scaling is handled per screen when SubMiner maps XWayland mpv coordinates to the overlay.
- 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.
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
@@ -420,7 +421,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:
- 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).
+5 -1
View File
@@ -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.
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
- `--log-level` controls logger verbosity.
- `--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).
- 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.
@@ -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.
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.
### Drag-and-Drop
+3 -1
View File
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
- **Client auth:** none
- **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
@@ -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.
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
```json
+1 -1
View File
@@ -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:
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.
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.
@@ -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.
**Parsed cue structure:**
```typescript
interface SubtitleCue {
startTime: number; // seconds
endTime: number; // seconds
text: string; // raw subtitle text
text: string; // plain text, decoded from the source format
}
```
**Supported formats:**
- 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 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: 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 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
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
### Dependency Analysis
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.
- **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.
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
// Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => {
const isKnown = nPlusOneEnabled
? token.isKnown || computeIsKnown(token, deps)
: false;
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
// Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined;
const jlptLevel = jlptEnabled
? computeJlptLevel(token, deps.getJlptLevel)
: undefined;
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
return { ...token, isKnown, frequencyRank, jlptLevel };
});
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -257,7 +260,7 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Combined Impact Summary
| Scenario | Before | After | Improvement |
|----------|--------|-------|-------------|
| --------------------------------- | ---------- | ---------- | ----------- |
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
@@ -267,16 +270,19 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Files Summary
### New Files
- `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts`
### Modified Files
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service)
### Test Files
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation)
+2
View File
@@ -25,6 +25,8 @@ 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`
- 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.
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
`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/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`
+34 -9
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-14
Last verified: 2026-08-04
Owner: Kyle Yasuda
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.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
payload.
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
synchronously, then replaces it with the tokenized payload when ready.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background
prefetch work and re-centers prefetch around the live playback time.
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
the matching `subtitleProcessingController` method, giving the visible overlay priority over
background prefetch work. Prefetch is not re-centered here: restarting the run per line
(`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
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
annotated subtitle payload to overlay windows and subtitle websocket listeners.
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
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
`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
+5 -7
View File
@@ -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', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
@@ -230,12 +230,12 @@ test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend res
WAYLAND_DISPLAY: 'wayland-0',
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', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
@@ -245,7 +245,7 @@ test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Way
XDG_CURRENT_DESKTOP: 'KDE',
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',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--vo=gpu',
'--gpu-api=opengl',
'--gpu-context=x11egl,x11',
'--gpu-context=x11vk,x11egl,x11',
'--window-maximized=yes',
],
);
+13
View File
@@ -80,6 +80,11 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
],
});
withWritableDb(remotePath, (db) => {
db.prepare(
`UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_key = 'showb-e1'`,
).run();
});
const summary = mergeSnapshotIntoDb(localPath, remotePath);
assert.equal(summary.sessionsMerged, 1);
@@ -126,6 +131,14 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
)?.video_id,
);
assert.equal(
queryOne<{ locked: number }>(
localPath,
'SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?',
[mergedVideoId],
)?.locked,
1,
);
assert.equal(
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
mergedVideoId,
+2 -1
View File
@@ -1,4 +1,4 @@
// Schema-version-18 shape of the tables the sync merge touches (plus the
// Current schema shape of the tables the sync merge touches (plus the
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
export const IMMERSION_DB_FIXTURE_DDL = `
@@ -39,6 +39,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
parser_source TEXT,
parser_confidence REAL,
parse_metadata_json TEXT,
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
watched INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
+11 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.5",
"version": "0.19.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -84,16 +84,18 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7"
"tmp": "0.2.7",
"undici": "7.29.0"
},
"keywords": [
"anki",
@@ -125,7 +127,7 @@
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.15.3",
"undici": "7.28.0",
"undici": "7.29.0",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
@@ -258,6 +260,10 @@
{
"from": "dist/launcher/subminer",
"to": "launcher/subminer"
},
{
"from": "CHANGELOG.md",
"to": "CHANGELOG.md"
}
]
},
+48
View File
@@ -0,0 +1,48 @@
## Highlights
### Added
- **In-App Changelog**
- View release notes without leaving the app, from the tray menu ("View Changelog") or the "What's New" button on update notifications.
- Shows notes for the latest published release even when it's newer than your installed build, and falls back to the notes bundled with your install if the download fails.
- Older versions fold automatically, your installed version is badged, and newer ones are tagged "New"; navigate with `J`/`K` or the arrow keys, `Enter` to expand/collapse, `R` to refresh, and `Esc` to close.
### Changed
- **Faster Subtitle Tokenization**
- Subtitle lines are parsed and looked up roughly twice as efficiently, with results cached across lines so repeated words and grammar no longer re-query the dictionary.
- Enabling a character dictionary no longer slows subtitle scanning as much, since name lookups now only check positions where a known name can actually start.
- Fixed related accuracy issues along the way: readings that could go missing on certain word endings, subtitle text that stayed unannotated after mining a card, character names that could drop out of disambiguation rules, and halfwidth-katakana character names that weren't recognized or read correctly.
### Fixed
- **Large Character Dictionary Generation**
- Big character dictionaries (long-running series like One Piece) no longer fail to install with a timeout error; the import time budget now scales with dictionary size instead of using a fixed 7-second limit.
- The "Generating character dictionary" notification now shows real progress (character/page counts, image download progress with an ETA, name-processing progress) and an elapsed-time clock, so a long-running import no longer looks frozen.
- **Stats Deletion Responsiveness**
- Deleting sessions, episodes, or library entries on the stats page no longer freezes the page or an active video player; deletes are now batched into a single transaction.
- **Subtitle Sidebar Clutter from Styled Subtitles**
- Heavily typeset subtitles (karaoke openings/endings, stylized signs) no longer flood the subtitle sidebar with garbled vector-drawing text or duplicate "shadow" copies of the same line.
- Subtitle text is now decoded consistently in one place, matching what mpv actually renders on screen, so it can no longer diverge or get cached inconsistently.
- **X11/XWayland Playback and Overlay Fixes**
- Fixed a crash on the first fullscreen toggle when using an mpv `gpu-next` shader (e.g. ArtCNN) in X11/XWayland mode; SubMiner no longer forces mpv onto its older OpenGL renderer.
- Fixed the overlay appearing oversized and offset from the video under fractional or mixed-monitor display scaling in X11/XWayland mode.
## What's Changed
- perf(tokenizer): single-pass Yomitan scan with cross-line caching and prefetch fixes by @ksyasuda in #185
- fix(subtitles): collapse duplicate ASS events and decode text once by @ksyasuda in #186
- feat(overlay): add in-app changelog modal by @ksyasuda in #187
- fix(playback): stop forcing legacy OpenGL renderer on X11 mpv backend by @ksyasuda in #188
- fix(dictionary): stop large character dictionaries from timing out by @ksyasuda in #189
- fix(overlay): handle X11 display scaling across monitors by @ksyasuda in #193
- fix(stats): batch deletes off the main thread by @ksyasuda in #194
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+7 -74
View File
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
import path from 'path';
import {
AnkiConnectConfig,
type CardKind,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
NotificationOptions,
type WordCardKind,
} from './types/anki';
import { AiConfig } from './types/integrations';
import type { KnownWordMaturityTier } from './types/subtitle';
@@ -50,6 +52,7 @@ import {
withUpdateProgress,
UiFeedbackState,
} from './anki-integration/ui-feedback';
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
import { PollingRunner } from './anki-integration/polling';
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
@@ -83,8 +86,6 @@ interface NoteInfo {
fields: Record<string, { value: string }>;
}
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -840,6 +841,7 @@ export class AnkiIntegration {
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
wordCardKind: WordCardKind;
} {
const lapis = this.getLapisConfig();
const kiku = this.getKikuConfig();
@@ -852,6 +854,7 @@ export class AnkiIntegration {
kikuEnabled: kiku.enabled,
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
};
}
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
availableFieldNames: string[],
cardKind: CardKind,
): void {
const audioFlagNames = ['IsAudioCard'];
if (cardKind === 'word-and-sentence') {
const wordAndSentenceFlag = this.resolveFieldName(
availableFieldNames,
'IsWordAndSentenceCard',
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
this.resolveFieldName(availableFieldNames, preferredName),
);
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(
@@ -4,29 +4,23 @@ import test from 'node:test';
import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
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];
function setWordAndSentenceCardTypeFields(
function setCardTypeFields(
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
cardKind: CardKind,
): void {
if (cardKind !== 'word-and-sentence') return;
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
applyCardKindFlagFields(
updatedFields,
cardKind,
(preferredName) =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
null,
);
}
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
setCardTypeFields: setWordAndSentenceCardTypeFields,
setCardTypeFields,
});
await service.updateLastAddedFromClipboard('字幕');
+6 -10
View File
@@ -3,7 +3,7 @@ import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki';
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -15,7 +15,7 @@ import {
resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions,
} from './media-source';
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume';
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
fields: Record<string, { value: string }>;
}
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
interface CardCreationClient {
addNote(
deck: string,
@@ -136,6 +134,7 @@ interface CardCreationDeps {
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
wordCardKind?: WordCardKind;
};
getFallbackDurationSeconds: () => number;
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
@@ -274,12 +273,9 @@ export class CardCreationService {
if (sentenceField) {
const processedSentence = this.deps.processSentence(sentence, fields);
updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
'word-and-sentence',
);
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
if (wordCardKind) {
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
}
updatePerformed = true;
}
+64
View File
@@ -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' });
});
+63
View File
@@ -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',
);
});
+60 -15
View File
@@ -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 {
fields: Record<string, { value: string }>;
}
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
}
export function shouldMarkWordAndSentenceCard(
noteInfo: NoteFieldValueInfo,
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
): boolean {
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
return false;
}
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
if (wordAndSentenceValue === null) {
return false;
const warnedMissingFlagFields = new Set<CardKind>();
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) {
return true;
}
return (
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
warnedMissingFlagFields.add(wordCardKind);
log.warn(
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
);
}
/**
* 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,
} from './note-update-workflow';
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>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
cardKind: CardKind,
): void {
assert.equal(cardKind, 'word-and-sentence');
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
applyCardKindFlagFields(
updatedFields,
cardKind,
(preferredName) =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
null,
);
}
function createWorkflowHarness() {
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
false,
processSentence: (text: string, _noteFields: Record<string, string>) => text,
setCardTypeFields: setWordAndSentenceCardTypeFields,
setCardTypeFields,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null;
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 () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
+7 -8
View File
@@ -1,7 +1,8 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
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 {
noteId: number;
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind;
};
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
setCardTypeFields: (
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
cardKind: CardKind,
) => void;
resolveConfiguredFieldName: (
noteInfo: NoteUpdateWorkflowNoteInfo,
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
'word-and-sentence',
);
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
if (wordCardKind) {
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
}
updatePerformed = true;
}
+8
View File
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}),
},
lapisKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
...(config.lapisKiku ?? {}),
},
} as AnkiConnectConfig;
}
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku,
lapisKiku:
patch.lapisKiku !== undefined
? { ...this.config.lapisKiku, ...patch.lapisKiku }
: this.config.lapisKiku,
};
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config);
+37
View File
@@ -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', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
lapisKiku: {
wordCardKind: 'word-and-sentence',
},
},
jimaku: {
apiBaseUrl: 'https://jimaku.cc',
@@ -1,4 +1,5 @@
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 {
NOTIFICATION_TYPE_VALUES,
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
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',
kind: 'string',
+1 -1
View File
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
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.',
'Most other AnkiConnect settings still require restart.',
],
+2
View File
@@ -1,6 +1,7 @@
import type { ResolveContext } from './context';
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
import { applyAnkiModernResolution } from './anki-connect/modern';
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
applyAnkiLapisKikuResolution(context, ankiConnect);
}
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
? (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;
}
+9 -1
View File
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
'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.',
'ankiConnect.isLapis.sentenceCardModel':
'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':
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css':
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.media.')) {
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' };
}
if (path.startsWith('ankiConnect.ai.')) {
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
@@ -0,0 +1,322 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
type ImmersionTrackerServiceCtor =
typeof import('../immersion-tracker-service').ImmersionTrackerService;
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
if (trackerCtor) return trackerCtor;
const mod = await import('../immersion-tracker-service');
trackerCtor = mod.ImmersionTrackerService;
return trackerCtor;
}
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-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 });
}
interface TrackerInternals {
db: DatabaseSync;
queue: unknown[];
recordWrite: (write: Record<string, unknown>) => void;
deleteSession: (sessionId: number) => Promise<void>;
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
rebuildLifetimeSummaries: () => Promise<unknown>;
reassignAnimeAnilist: (animeId: number, info: { anilistId: number }) => Promise<void>;
flushNow: () => void;
writeLock: { locked: boolean };
}
test('delete maintenance fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let deleteRunnerCalls = 0;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath, policy: { batchSize: 2 } },
{
runDeleteMaintenanceTask: async () => {
deleteRunnerCalls += 1;
},
},
);
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
let flushCalls = 0;
internals.flushNow = () => {
flushCalls += 1;
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
};
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
assert.equal(flushCalls, 1);
assert.equal(deleteRunnerCalls, 0);
assert.equal(internals.writeLock.locked, false);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
/queue did not drain/i,
);
assert.deepEqual(
internals.db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all(),
[
{ animeId: 1, anilistId: null },
{ animeId: 2, anilistId: 123 },
],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('mergeAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
assert.deepEqual(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
.all()
.map((row) => (row as { animeId: number }).animeId),
[1, 2],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
assert.equal(
(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
.get() as {
animeId: number;
}
).animeId,
2,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
function seedTwoEntries(db: DatabaseSync): void {
db.exec(`
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000);
`);
}
function queueSubtitleLines(tracker: TrackerInternals, count: number): void {
for (let index = 0; index < count; index += 1) {
tracker.recordWrite({
kind: 'subtitleLine',
sessionId: 1,
videoId: 2,
lineIndex: index,
segmentStartMs: index * 1000,
segmentEndMs: index * 1000 + 900,
text: `line ${index}`,
wordOccurrences: [],
kanjiOccurrences: [],
firstSeen: 1000,
lastSeen: 2000,
});
}
}
/**
* Queued last so it sits past the first batch. Lifetime `total_lines_seen`
* reads this counter, not a COUNT over imm_subtitle_lines, so the rebuilt
* summary only reflects the session once the queue is drained all the way.
*/
function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
tracker.recordWrite({
kind: 'telemetry',
sessionId: 1,
sampleMs: 3000,
lastMediaMs: 3000,
totalWatchedMs: 4000,
activeWatchedMs: 3500,
linesSeen,
tokensSeen: linesSeen * 5,
cardsMined: 2,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
pauseCount: 0,
pauseMs: 0,
seekForwardCount: 0,
seekBackwardCount: 0,
mediaBufferEvents: 0,
});
}
/** The queued telemetry sample only exists in the database once the queue drained fully. */
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
const row = db
.prepare(
`SELECT lines_seen AS linesSeen
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1`,
)
.get(sessionId) as { linesSeen: number } | undefined;
return row ? Number(row.linesSeen) : null;
}
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
const row = db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
.get(animeId) as { total: number };
return Number(row.total);
}
/**
* Both entry points must see a settled database before changing episode
* ownership. A single flushNow() only writes one batch off the front of the
* queue, so anything past `batchSize` would still be unwritten when the merge
* repoints rows.
*/
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 8);
queueTelemetry(internals, 8);
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
await internals.mergeAnime(1, [2]);
assert.equal(internals.queue.length, 0);
// Every queued line landed, attributed to the surviving entry.
assert.equal(countLinesForAnime(internals.db, 1), 8);
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 8);
queueTelemetry(internals, 8);
await internals.moveVideoToAnime(2, 1);
assert.equal(internals.queue.length, 0);
assert.equal(countLinesForAnime(internals.db, 1), 8);
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
@@ -1053,6 +1053,55 @@ describe('stats server API routes', () => {
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
});
it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
const app = createStatsApp(
createMockTracker({
getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
});
});
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
let dismissedId: number | null = null;
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async (recommendationId: number) => {
dismissedId = recommendationId;
return true;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
method: 'DELETE',
});
assert.equal(res.status, 200);
assert.equal(dismissedId, 4);
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async () => false,
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
method: 'DELETE',
});
assert.equal(res.status, 404);
});
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1');
@@ -2454,6 +2503,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 () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -2950,6 +3073,148 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
const app = createStatsApp(
createMockTracker({
mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
merged = { targetAnimeId, sourceAnimeIds };
return {
survivingAnimeId: targetAnimeId,
mergedAnimeIds: sourceAnimeIds,
movedVideos: 3,
};
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The target repeated in the sources must not delete the entry we keep.
body: '{"sourceAnimeIds":[8,9,8,7]}',
});
assert.equal(res.status, 200);
assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
assert.deepEqual(await res.json(), {
ok: true,
animeId: 7,
mergedAnimeIds: [8, 9],
movedVideos: 3,
});
});
it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
let mergeCalls = 0;
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
mergeCalls += 1;
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
},
} as Partial<ImmersionTrackerService>),
);
for (const body of [
'{"sourceAnimeIds":[]}',
'{"sourceAnimeIds":[7]}',
'{"sourceAnimeIds":0}',
]) {
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
assert.equal(res.status, 400);
}
assert.equal(mergeCalls, 0);
});
it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
let moved: { videoId: number; animeId: number } | null = null;
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async (videoId: number, animeId: number) => {
moved = { videoId, animeId };
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":7}',
});
assert.equal(res.status, 200);
assert.deepEqual(moved, { videoId: 12, animeId: 7 });
assert.deepEqual(await res.json(), {
ok: true,
animeId: 7,
previousAnimeId: 4,
removedPreviousAnime: true,
});
});
it('POST /api/stats/anime/:animeId/merge reports a merge that folded nothing as 404', async () => {
const app = createStatsApp(
createMockTracker({
mergeAnime: async (targetAnimeId: number) => ({
survivingAnimeId: targetAnimeId,
mergedAnimeIds: [],
movedVideos: 0,
}),
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"sourceAnimeIds":[8]}',
});
assert.equal(res.status, 404);
});
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async () => {
throw new Error('Unknown episode or target library entry');
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":99}',
});
assert.equal(res.status, 404);
});
it('PATCH /api/stats/media/:videoId/anime does not disguise storage failures as 404', async () => {
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async () => {
throw new Error('database is locked');
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":7}',
});
assert.notEqual(res.status, 404);
assert.equal(res.status >= 500, true);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
@@ -327,6 +327,7 @@ export function createCoverArtFetcher(
titleEnglish: selected.title?.english ?? null,
titleNative: selected.title?.native ?? null,
episodesTotal: selected.episodes ?? null,
exactTitleMatch: resolution?.exactTitleMatch ?? false,
});
logger.info(
@@ -156,6 +156,79 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
assert.deepEqual(relationLookups, []);
});
test('a sequel resolution is not certified by the anchor exact-title evidence', async () => {
// The anchor matched the search title exactly, but the hopped-to entry is a
// different inference (a split-cour chain can land one season short), so the
// sequel result must report its own title evidence, not the anchor's.
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
{ execute },
);
assert.equal(result?.id, 20698);
assert.equal(result?.via, 'sequel-chain');
assert.equal(result?.exactTitleMatch, false);
});
test('a sequel resolution whose own title matches the parsed title stays exact', async () => {
const anchor: AnilistSeasonMedia = {
id: 1,
episodes: 12,
format: 'TV',
title: { english: 'Show' },
};
const sequel: AnilistSeasonMedia = {
id: 2,
episodes: 12,
format: 'TV',
title: { english: 'Show 2nd Season' },
};
const { execute } = createExecutor([anchor], {
1: [{ relationType: 'SEQUEL', node: sequel }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'Show 2nd Season', season: 2, episode: 1 },
{ execute },
);
assert.equal(result?.id, 2);
assert.equal(result?.via, 'sequel-chain');
assert.equal(result?.exactTitleMatch, true);
});
test('reports an exact normalized synonym match as strong evidence', async () => {
const { execute } = createExecutor([
{
id: 1,
episodes: 12,
format: 'TV',
title: { english: 'Hitori Gotoh Story' },
synonyms: ['BOCCHI THE ROCK'],
},
]);
const result = await resolveAnilistSeasonMedia({ title: 'Bocchi the Rock!' }, { execute });
assert.equal(result?.exactTitleMatch, true);
});
test('reports a fuzzy-only search result as weak evidence', async () => {
const { execute } = createExecutor([
{
id: 1,
episodes: 12,
format: 'TV',
title: { english: 'Actual Show' },
},
]);
const result = await resolveAnilistSeasonMedia({ title: 'Unrelated Release' }, { execute });
assert.equal(result?.exactTitleMatch, false);
});
test('strips a season marker already present in the parsed title', async () => {
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
+40 -12
View File
@@ -9,6 +9,8 @@
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
*/
import { normalizeTitleIdentity } from '../../utils/title-normalization';
export interface AnilistSeasonMediaTitle {
romaji?: string | null;
english?: string | null;
@@ -42,6 +44,8 @@ export interface AnilistSeasonResolution {
seasonResolved: boolean;
requestedSeason: number | null;
via: AnilistSeasonResolutionVia;
/** Exact normalized match against an AniList title or synonym. */
exactTitleMatch: boolean;
}
export interface ResolveAnilistSeasonMediaInput {
@@ -115,10 +119,6 @@ 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".
@@ -136,7 +136,7 @@ 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));
.map((value) => normalizeTitleIdentity(value));
}
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
@@ -176,6 +176,7 @@ function toResolution(
season: number | null,
via: AnilistSeasonResolutionVia,
seasonResolved: boolean,
exactTitleMatch: boolean,
): AnilistSeasonResolution {
return {
id: media.id,
@@ -185,6 +186,7 @@ function toResolution(
seasonResolved,
requestedSeason: season,
via,
exactTitleMatch,
};
}
@@ -209,9 +211,10 @@ export function pickAnchorMedia(
: 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 targets = [
normalizeTitleIdentity(title),
normalizeTitleIdentity(stripSeasonSuffix(title)),
].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
const scored = pool.map((entry, index) => {
const candidateTitles = mediaTitles(entry);
@@ -367,9 +370,20 @@ export async function resolveAnilistSeasonMedia(
episode: season === null || season <= 1 ? input.episode : null,
});
if (!anchor) return null;
// Certifies the media actually returned, never the anchor on its behalf: a
// sequel-chain hop can land one season short (split-cour entries) while the
// anchor title still matches perfectly, and that certainty must not carry
// over to the hopped-to entry.
const exactMatchFor = (candidate: AnilistSeasonMedia): boolean => {
const titles = mediaTitles(candidate);
return (
titles.includes(normalizeTitleIdentity(searchTitle)) ||
titles.includes(normalizeTitleIdentity(input.title))
);
};
if (season === null || season <= 1) {
return toResolution(anchor, searchTitle, season, 'anchor', true);
return toResolution(anchor, searchTitle, season, 'anchor', true, exactMatchFor(anchor));
}
let chainError: unknown = null;
@@ -383,7 +397,14 @@ export async function resolveAnilistSeasonMedia(
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
);
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
return toResolution(
viaChain,
searchTitle,
season,
'sequel-chain',
true,
exactMatchFor(viaChain),
);
}
const viaAirOrder = pickByAirOrder(anchor, season, media);
@@ -391,7 +412,14 @@ export async function resolveAnilistSeasonMedia(
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
);
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
return toResolution(
viaAirOrder,
searchTitle,
season,
'air-order',
true,
exactMatchFor(viaAirOrder),
);
}
// The chain failed for transport reasons rather than because the season is absent;
@@ -403,5 +431,5 @@ export async function resolveAnilistSeasonMedia(
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);
return toResolution(anchor, searchTitle, season, 'anchor', false, exactMatchFor(anchor));
}
+195
View File
@@ -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);
});
+280
View File
@@ -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);
}
+1
View File
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
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 () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -1785,6 +2132,78 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
}
});
test('local parsing reuses a unique compatible manual assignment from the same directory', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const anchorPath = '/tmp/grouped/Incorrect Name S01E01.mkv';
tracker.handleMediaChange(anchorPath, 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as {
db: DatabaseSync;
sessionState: { videoId: number } | null;
};
const anchorVideoId = privateApi.sessionState?.videoId;
assert.ok(anchorVideoId);
tracker.handleMediaChange(null, null);
const timestamp = toDbTimestamp(trackerNowMs());
const target = privateApi.db
.prepare(
`
INSERT INTO imm_anime (
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES ('correct show season 1', 'Correct Show Season 1', ?, ?)
RETURNING anime_id AS animeId
`,
)
.get(timestamp, timestamp) as { animeId: number };
await tracker.moveVideoToAnime(anchorVideoId, target.animeId);
tracker.handleMediaChange(anchorPath, 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange('/tmp/grouped/Another Wrong Name S01E02.mkv', 'Episode 2');
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange('/tmp/grouped/Another Wrong Name S02E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
const rows = privateApi.db
.prepare(
`
SELECT source_path AS sourcePath, anime_id AS animeId, anime_assignment_locked AS locked
FROM imm_videos
WHERE source_path LIKE '/tmp/grouped/%'
ORDER BY source_path
`,
)
.all() as Array<{ sourcePath: string; animeId: number; locked: number }>;
const assignments = new Map(rows.map((row) => [row.sourcePath, row]));
assert.deepEqual(assignments.get(anchorPath), {
sourcePath: anchorPath,
animeId: target.animeId,
locked: 1,
});
assert.deepEqual(assignments.get('/tmp/grouped/Another Wrong Name S01E02.mkv'), {
sourcePath: '/tmp/grouped/Another Wrong Name S01E02.mkv',
animeId: target.animeId,
locked: 0,
});
assert.notEqual(
assignments.get('/tmp/grouped/Another Wrong Name S02E01.mkv')?.animeId,
target.animeId,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('handleMediaChange splits matching parsed titles across distinct seasons', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -2271,6 +2690,67 @@ test('Jellyfin playback metadata links stream videos to existing series title',
}
});
test('Jellyfin metadata refresh preserves a manual episode assignment', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const metadata = {
mediaPath: 'http://jellyfin.local/Videos/item-locked/stream?api_key=token',
displayTitle: 'Parsed Show S01E01',
itemTitle: 'Episode 1',
seriesTitle: 'Parsed Show',
seasonNumber: 1,
episodeNumber: 1,
itemId: 'item-locked',
};
tracker.recordJellyfinPlaybackMetadata(metadata);
const privateApi = tracker as unknown as { db: DatabaseSync };
const video = privateApi.db.prepare('SELECT video_id AS videoId FROM imm_videos').get() as {
videoId: number;
};
const timestamp = toDbTimestamp(trackerNowMs());
const target = privateApi.db
.prepare(
`
INSERT INTO imm_anime (
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES ('correct show', 'Correct Show', ?, ?)
RETURNING anime_id AS animeId
`,
)
.get(timestamp, timestamp) as { animeId: number };
await tracker.moveVideoToAnime(video.videoId, target.animeId);
tracker.recordJellyfinPlaybackMetadata(metadata);
const assignment = privateApi.db
.prepare(
`
SELECT anime_id AS animeId, anime_assignment_locked AS locked
FROM imm_videos
WHERE video_id = ?
`,
)
.get(video.videoId) as { animeId: number; locked: number };
assert.equal(assignment.animeId, target.animeId);
assert.equal(assignment.locked, 1);
const animeCount = privateApi.db.prepare('SELECT COUNT(*) AS count FROM imm_anime').get() as {
count: number;
};
assert.equal(animeCount.count, 1);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup repairs existing Jellyfin stream video links to metadata rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -3457,6 +3937,22 @@ test('reassignAnimeAnilist redistributes conflicting legacy combined row before
(1, 2000, 1000, 1000, 1, 10, 0, 0, 0, 0, 0, 0, 0, 0),
(2, 4000, 2000, 2000, 2, 20, 0, 0, 0, 0, 0, 0, 0, 0),
(3, 6000, 3000, 3000, 3, 30, 0, 0, 0, 0, 0, 0, 0, 0);
-- The per-video lifetime rows those finalized sessions would have left
-- behind; redistributing videos re-derives imm_lifetime_anime from these.
INSERT INTO imm_lifetime_media (
video_id,
total_sessions,
total_active_ms,
completed,
first_watched_ms,
last_watched_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 1, 1000, 0, '1000', '2000', 1000, 2000),
(2, 1, 2000, 0, '3000', '4000', 3000, 4000),
(3, 1, 3000, 0, '5000', '6000', 5000, 6000);
`);
await tracker.reassignAnimeAnilist(2, {
+163 -24
View File
@@ -16,6 +16,8 @@ import {
applyPragmas,
createTrackerPreparedStatements,
ensureSchema,
findManualDirectoryAnimeAssignment,
getManualAnimeAssignment,
executeQueuedWrite,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
@@ -28,6 +30,7 @@ import {
} from './immersion-tracker/storage';
import {
applySessionLifetimeSummary,
recomputeLifetimeAnimeAggregates,
reconcileStaleActiveSessions,
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
shouldBackfillLifetimeSummaries,
@@ -83,19 +86,29 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
deleteAnime as deleteAnimeQuery,
deleteSession as deleteSessionQuery,
deleteSessions as deleteSessionsQuery,
deleteVideo as deleteVideoQuery,
getVideoDurationMs,
markVideoWatched,
upsertCoverArt,
} 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 {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
type AnimeMergeRecommendation,
} from './immersion-tracker/anime-season-repair';
import {
mergeAnimeRecords,
moveVideoToAnime as moveVideoToAnimeQuery,
type AnimeMergeSummary,
type VideoMoveSummary,
} from './immersion-tracker/anime-merge';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -182,6 +195,7 @@ const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000;
const DELETE_MAINTENANCE_BATCH_WINDOW_MS = 10;
function isValidYouTubeVideoId(value: string | null): boolean {
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
@@ -385,6 +399,8 @@ export class ImmersionTrackerService {
private readonly vacuumIntervalMs: number;
private readonly dbPath: string;
private readonly writeLock = { locked: false };
private readonly destroyDeleteMaintenanceRunner: () => void;
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
private flushScheduled = false;
@@ -406,9 +422,37 @@ export class ImmersionTrackerService {
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
| undefined;
constructor(options: ImmersionTrackerOptions) {
constructor(
options: ImmersionTrackerOptions,
dependencies: {
runDeleteMaintenanceTask?: RunDeleteMaintenanceTask;
destroyDeleteMaintenanceRunner?: () => void;
} = {},
) {
this.dbPath = options.dbPath;
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.requireWriteQueueDrained('delete maintenance');
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);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
@@ -485,7 +529,7 @@ export class ImmersionTrackerService {
this.logger.info(
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
);
rebuildLifetimeSummaryTables(this.db);
recomputeLifetimeAnimeAggregates(this.db);
}
if (shouldBackfillLifetimeSummaries(this.db)) {
const result = rebuildLifetimeSummaryTables(this.db);
@@ -512,6 +556,8 @@ export class ImmersionTrackerService {
}
this.finalizeActiveSession();
this.isDestroyed = true;
this.deleteMaintenanceScheduler.destroy();
this.destroyDeleteMaintenanceRunner();
this.db.close();
}
@@ -596,8 +642,7 @@ export class ImmersionTrackerService {
}
async rebuildLifetimeSummaries(): Promise<LifetimeRebuildSummary> {
this.flushTelemetry(true);
this.flushNow();
this.requireWriteQueueDrained('rebuilding lifetime summaries');
return rebuildLifetimeSummaryTables(this.db);
}
@@ -664,6 +709,14 @@ export class ImmersionTrackerService {
return getAnimeLibrary(this.db);
}
async getAnimeMergeRecommendations(): Promise<AnimeMergeRecommendation[]> {
return getAnimeMergeRecommendations(this.db);
}
async dismissAnimeMergeRecommendation(recommendationId: number): Promise<boolean> {
return dismissAnimeMergeRecommendation(this.db, recommendationId);
}
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
this.relinkYoutubeAnimeLibrary();
return getAnimeDetail(this.db, animeId);
@@ -709,10 +762,11 @@ export class ImmersionTrackerService {
this.logger.warn(`Ignoring delete request for active immersion session ${sessionId}`);
return;
}
deleteSessionQuery(this.db, sessionId);
await this.enqueueDeleteMaintenanceTask(() => ({ kind: 'session', sessionId }));
}
async deleteSessions(sessionIds: number[]): Promise<void> {
await this.enqueueDeleteMaintenanceTask(() => {
const activeSessionId = this.sessionState?.sessionId;
const deletableSessionIds =
activeSessionId === undefined
@@ -723,21 +777,25 @@ export class ImmersionTrackerService {
`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> {
await this.enqueueDeleteMaintenanceTask(() => {
if (this.sessionState?.videoId === videoId) {
this.logger.warn(`Ignoring delete request for active immersion video ${videoId}`);
return;
return null;
}
deleteVideoQuery(this.db, videoId);
return { kind: 'video', videoId };
});
}
async deleteAnime(animeId: number): Promise<void> {
// The active video's anime link is assigned asynchronously after the title
// is parsed, so a guard reading imm_videos too early sees a null and lets
// the delete through — then the late update recreates the anime row.
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);
@@ -750,10 +808,77 @@ export class ImmersionTrackerService {
.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;
return null;
}
}
deleteAnimeQuery(this.db, animeId);
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'));
}
return this.deleteMaintenanceScheduler.enqueue(resolveTask);
}
/**
* Fold duplicate library entries into one. Sources that hold the currently
* playing episode are fine: the videos move, nothing is deleted out from
* under the active session.
*/
async mergeAnime(targetAnimeId: number, sourceAnimeIds: number[]): Promise<AnimeMergeSummary> {
const pendingVideoId = this.sessionState?.videoId;
if (pendingVideoId !== undefined) {
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
}
// This rebuilds the lifetime summaries, which recompute from the database:
// queued writes have to land first or the active session is dropped from
// the merged totals.
this.requireWriteQueueDrained('merging library entries');
return mergeAnimeRecords(this.db, targetAnimeId, sourceAnimeIds);
}
async moveVideoToAnime(videoId: number, targetAnimeId: number): Promise<VideoMoveSummary> {
await this.pendingAnimeMetadataUpdates.get(videoId);
this.requireWriteQueueDrained('moving an episode');
return moveVideoToAnimeQuery(this.db, videoId, targetAnimeId);
}
/**
* Persist every queued write before a caller recomputes summaries from the
* database.
*
* A single `flushNow()` is not enough: forced telemetry is appended to the
* back of the queue while `flushNow()` writes at most `batchSize` entries off
* the front, so a busy session leaves the newest sample unwritten. Stops as
* soon as a pass makes no progress a rolled-back batch is pushed back onto
* the queue, and looping on that would spin forever.
*
* Returns false when the queue could not be emptied. Summary-rebuilding
* callers fail closed in that case.
*/
private drainWriteQueue(context: string): boolean {
this.flushTelemetry(true);
while (this.queue.length > 0) {
const pending = this.queue.length;
this.flushNow();
if (this.queue.length >= pending) {
this.logger.warn(
`Immersion tracker queue did not drain before ${context}; summaries may lag by ${this.queue.length} writes`,
);
return false;
}
}
return true;
}
private requireWriteQueueDrained(context: string): void {
if (!this.drainWriteQueue(context)) {
throw new Error(`Immersion tracker queue did not drain before ${context}`);
}
}
async reassignAnimeAnilist(
@@ -768,7 +893,14 @@ export class ImmersionTrackerService {
coverUrl?: string | null;
},
): Promise<void> {
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
this.requireWriteQueueDrained('reassigning an AniList entry');
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) return;
this.db
.prepare(
`
@@ -795,7 +927,7 @@ export class ImmersionTrackerService {
animeId,
);
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaryTables(this.db);
recomputeLifetimeAnimeAggregates(this.db);
}
// Update cover art for all videos in this anime
@@ -1243,7 +1375,7 @@ export class ImmersionTrackerService {
metadataJson: candidate.metadataJson,
});
}
rebuildLifetimeSummaryTables(this.db);
recomputeLifetimeAnimeAggregates(this.db);
}
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
@@ -1291,7 +1423,9 @@ export class ImmersionTrackerService {
seasonNumber,
episodeNumber,
});
const animeId = getOrCreateAnimeRecord(this.db, {
const animeId =
getManualAnimeAssignment(this.db, videoId) ??
getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle,
canonicalTitle: libraryTitle,
seasonScope: seasonNumber,
@@ -1316,7 +1450,7 @@ export class ImmersionTrackerService {
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
);
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
rebuildLifetimeSummaryTables(this.db);
recomputeLifetimeAnimeAggregates(this.db);
}
}
@@ -1811,7 +1945,7 @@ export class ImmersionTrackerService {
}
private runMaintenance(): void {
if (this.isDestroyed) return;
if (this.isDestroyed || this.writeLock.locked) return;
try {
this.flushTelemetry(true);
this.flushNow();
@@ -1937,7 +2071,12 @@ export class ImmersionTrackerService {
return;
}
const animeId = getOrCreateAnimeRecord(this.db, {
const animeId =
getManualAnimeAssignment(this.db, videoId) ??
(mediaPath && !isRemoteSource(mediaPath)
? findManualDirectoryAnimeAssignment(this.db, videoId, mediaPath, parsed.parsedSeason)
: null) ??
getOrCreateAnimeRecord(this.db, {
parsedTitle: parsed.parsedTitle,
canonicalTitle: parsed.parsedTitle,
seasonScope: parsed.parsedSeason,
@@ -0,0 +1,896 @@
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 {
applyPragmas,
ensureSchema,
findManualDirectoryAnimeAssignment,
getManualAnimeAssignment,
getOrCreateAnimeRecord,
linkVideoToAnimeRecord,
} from '../storage.js';
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
resolveAnimeAnilistConflict,
} from '../anime-season-repair.js';
import { updateAnimeAnilistInfo } from '../query-maintenance.js';
const BASE_MS = 1_700_000_000_000;
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anime-merge-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 });
}
function withDb(work: (db: DatabaseSync) => void): void {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
work(db);
} finally {
db.close();
cleanupDbPath(dbPath);
}
}
interface AnimeSeed {
animeId: number;
key: string;
title: string;
anilistId?: number | null;
titleRomaji?: string | null;
}
function insertAnime(db: DatabaseSync, seed: AnimeSeed): void {
db.prepare(
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, title_romaji, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
seed.animeId,
seed.key,
seed.title,
seed.anilistId ?? null,
seed.titleRomaji ?? null,
BASE_MS,
BASE_MS,
);
}
interface EpisodeSeed {
videoId: number;
animeId: number;
season?: number | null;
episode?: number;
activeMs?: number;
cards?: number;
}
/**
* One episode with one ended session, plus the imm_lifetime_media row the
* session would have left behind, so lifetime aggregates have something to sum.
*/
function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
const activeMs = seed.activeMs ?? 1000;
const cards = seed.cards ?? 1;
db.prepare(
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, 1, 'Show', ?, ?, 1, 1440000, ?, ?)`,
).run(
seed.videoId,
`local:/tmp/show-${seed.videoId}.mkv`,
seed.animeId,
`Show ${seed.videoId}`,
seed.season ?? null,
seed.episode ?? seed.videoId,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, cards_mined, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, 2, ?, ?, ?, ?)`,
).run(
seed.videoId,
`session-${seed.videoId}`,
seed.videoId,
String(BASE_MS),
String(BASE_MS + activeMs),
activeMs,
cards,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, 1, ?, ?, ?)`,
).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.videoId}`, BASE_MS, BASE_MS);
db.prepare(
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, 1, ?, ?, 1, ?, ?, ?, ?)`,
).run(
seed.videoId,
activeMs,
cards,
String(BASE_MS),
String(BASE_MS + activeMs),
BASE_MS,
BASE_MS,
);
}
function animeIds(db: DatabaseSync): number[] {
return (
db.prepare('SELECT anime_id AS id FROM imm_anime ORDER BY anime_id').all() as Array<{
id: number;
}>
).map((row) => row.id);
}
function videoAnimeId(db: DatabaseSync, videoId: number): number | null {
return (
db.prepare('SELECT anime_id AS id FROM imm_videos WHERE video_id = ?').get(videoId) as {
id: number | null;
}
).id;
}
function assignmentLocked(db: DatabaseSync, videoId: number): number {
return (
db
.prepare('SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?')
.get(videoId) as { locked: number }
).locked;
}
function lineAnimeIds(db: DatabaseSync, animeId: number): number {
return Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
.get(animeId) as { total: number }
).total,
);
}
test('mergeAnimeRecords folds episodes, lines and lifetime totals into the target', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1', anilistId: 555 });
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
const summary = mergeAnimeRecords(db, 1, [2]);
assert.equal(summary.survivingAnimeId, 1);
assert.deepEqual(summary.mergedAnimeIds, [2]);
assert.equal(summary.movedVideos, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(lineAnimeIds(db, 1), 2);
const lifetime = db
.prepare(
'SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes FROM imm_lifetime_anime WHERE anime_id = 1',
)
.get() as { activeMs: number; cards: number; episodes: number };
assert.equal(lifetime.activeMs, 3000);
assert.equal(lifetime.cards, 4);
assert.equal(lifetime.episodes, 2);
});
});
test('merge and move preserve lifetime history whose raw sessions were pruned', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertAnime(db, { animeId: 3, key: 'other show', title: 'Other Show' });
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
insertEpisode(db, { videoId: 3, animeId: 3, activeMs: 4000, cards: 5 });
// Retention pruned every raw session; only the lifetime summaries remain.
db.exec('DELETE FROM imm_sessions');
db.prepare(
`UPDATE imm_lifetime_global
SET total_sessions = 200, total_active_ms = 360000000, total_cards = 500, active_days = 90
WHERE global_id = 1`,
).run();
mergeAnimeRecords(db, 1, [2]);
moveVideoToAnime(db, 3, 1);
const globalRow = db
.prepare(
`SELECT total_sessions AS sessions, total_active_ms AS activeMs, total_cards AS cards, active_days AS days
FROM imm_lifetime_global WHERE global_id = 1`,
)
.get() as { sessions: number; activeMs: number; cards: number; days: number };
assert.equal(globalRow.sessions, 200);
assert.equal(globalRow.activeMs, 360000000);
assert.equal(globalRow.cards, 500);
assert.equal(globalRow.days, 90);
const survivor = db
.prepare(
`SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes
FROM imm_lifetime_anime WHERE anime_id = 1`,
)
.get() as { activeMs: number; cards: number; episodes: number };
assert.equal(survivor.activeMs, 7000);
assert.equal(survivor.cards, 9);
assert.equal(survivor.episodes, 3);
assert.equal(
db.prepare('SELECT 1 FROM imm_lifetime_anime WHERE anime_id = 3').get(),
undefined,
);
});
});
test('mergeAnimeRecords repoints subtitle lines recorded before the anime link landed', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
// Lines are written with the video's anime_id at the time, which is NULL
// until the async title parse assigns one.
db.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (2, 2, NULL, 2, 'unlinked line', ?, ?)`,
).run(BASE_MS, BASE_MS);
mergeAnimeRecords(db, 1, [2]);
assert.equal(lineAnimeIds(db, 1), 3);
const orphaned = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id IS NULL')
.get() as { total: number }
).total,
);
assert.equal(orphaned, 0);
});
});
test('mergeAnimeRecords inherits metadata the target is missing without clobbering its own', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', titleRomaji: 'Shou' });
insertAnime(db, {
animeId: 2,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 555,
titleRomaji: 'Show Romaji',
});
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
mergeAnimeRecords(db, 1, [2]);
const row = db
.prepare(
'SELECT canonical_title AS title, anilist_id AS anilistId, title_romaji AS romaji FROM imm_anime WHERE anime_id = 1',
)
.get() as { title: string; anilistId: number | null; romaji: string | null };
assert.equal(row.title, 'Show');
// anilist_id is UNIQUE, so inheriting it proves the source row was gone first.
assert.equal(row.anilistId, 555);
assert.equal(row.romaji, 'Shou');
});
});
test('mergeAnimeRecords preserves source title identities as aliases of the survivor', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
db.prepare(
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('show s01', 2, ?, ?)`,
).run(BASE_MS, BASE_MS);
mergeAnimeRecords(db, 1, [2]);
const fromSourceTitle = getOrCreateAnimeRecord(db, {
parsedTitle: 'Show Season 1',
canonicalTitle: 'Show Season 1',
seasonScope: 1,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const fromTransferredAlias = getOrCreateAnimeRecord(db, {
parsedTitle: 'Show S01',
canonicalTitle: 'Show S01',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
assert.equal(fromSourceTitle, 1);
assert.equal(fromTransferredAlias, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(
(
db.prepare('SELECT canonical_title AS title FROM imm_anime WHERE anime_id = 1').get() as {
title: string;
}
).title,
'Show',
);
});
});
test('mergeAnimeRecords ignores unknown targets and self-merges', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
assert.deepEqual(mergeAnimeRecords(db, 99, [1]).mergedAnimeIds, []);
assert.deepEqual(mergeAnimeRecords(db, 1, [1]).mergedAnimeIds, []);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 1), 1);
});
});
test('moveVideoToAnime moves one episode and prunes the emptied entry', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray Episode Title', anilistId: 777 });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, activeMs: 5000, cards: 2 });
const summary = moveVideoToAnime(db, 2, 1);
assert.equal(summary.targetAnimeId, 1);
assert.equal(summary.previousAnimeId, 2);
assert.equal(summary.removedPreviousAnime, true);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(assignmentLocked(db, 2), 1);
assert.equal(getManualAnimeAssignment(db, 2), 1);
assert.equal(lineAnimeIds(db, 1), 2);
const lifetime = db
.prepare('SELECT total_active_ms AS activeMs FROM imm_lifetime_anime WHERE anime_id = 1')
.get() as { activeMs: number };
assert.equal(lifetime.activeMs, 6000);
// The stray entry's AniList link is dropped, not inherited: a move makes no
// claim that the two entries are the same show.
const target = db
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 1')
.get() as { anilistId: number | null };
assert.equal(target.anilistId, null);
});
});
test('moveVideoToAnime is a no-op when the episode is already in the target entry', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
const summary = moveVideoToAnime(db, 1, 1);
assert.equal(summary.targetAnimeId, 1);
assert.equal(summary.previousAnimeId, 1);
assert.equal(summary.removedPreviousAnime, false);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(assignmentLocked(db, 1), 1);
});
});
test('automatic metadata cannot overwrite a manual episode assignment', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray' });
insertAnime(db, { animeId: 3, key: 'parser result', title: 'Parser Result' });
insertEpisode(db, { videoId: 1, animeId: 2, season: 1 });
moveVideoToAnime(db, 1, 1);
linkVideoToAnimeRecord(db, 1, {
animeId: 3,
parsedBasename: 'Parser Result S01E01.mkv',
parsedTitle: 'Parser Result',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'guessit',
parserConfidence: 1,
parseMetadataJson: null,
});
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(getManualAnimeAssignment(db, 1), 1);
const parsedTitle = db
.prepare('SELECT parsed_title AS parsedTitle FROM imm_videos WHERE video_id = 1')
.get() as { parsedTitle: string | null };
assert.equal(parsedTitle.parsedTitle, 'Parser Result');
});
});
test('directory grouping requires one season-compatible manual destination', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray' });
insertAnime(db, { animeId: 3, key: 'other', title: 'Other' });
insertEpisode(db, { videoId: 1, animeId: 2, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 3, season: 1 });
insertEpisode(db, { videoId: 3, animeId: 3, season: 1 });
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
'/library/show/Show S01E01.mkv',
1,
);
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
'/library/show/Stray S01E02.mkv',
2,
);
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
'/library/show/Other S01E03.mkv',
3,
);
moveVideoToAnime(db, 1, 1);
assert.equal(findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S01E02.mkv', 1), 1);
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S02E02.mkv', 2),
null,
);
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/other/Stray S01E02.mkv', 1),
null,
);
moveVideoToAnime(db, 3, 3);
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S01E02.mkv', 1),
null,
);
});
});
test('moveVideoToAnime keeps the source entry when other episodes remain', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'other', title: 'Other' });
insertEpisode(db, { videoId: 1, animeId: 2 });
insertEpisode(db, { videoId: 2, animeId: 2 });
const summary = moveVideoToAnime(db, 2, 1);
assert.equal(summary.removedPreviousAnime, false);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 2);
assert.equal(videoAnimeId(db, 2), 1);
});
});
test('moveVideoToAnime rejects unknown episodes and targets', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
assert.throws(() => moveVideoToAnime(db, 99, 1));
assert.throws(() => moveVideoToAnime(db, 1, 99));
assert.equal(videoAnimeId(db, 1), 1);
});
});
test('resolveAnimeAnilistConflict folds a seasonless duplicate into the entry that owns the id', () => {
withDb((db) => {
// Same show, split because one release tagged S01 and the other did not.
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.survivingAnimeId, 1);
assert.equal(summary.movedVideos, 1);
assert.equal(summary.deletedAnimeRows, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
});
});
test('resolveAnimeAnilistConflict recommends a weak title collision instead of merging it', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('automatic AniList update leaves a weak collision unassigned for user review', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Actual Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: false,
});
const target = db
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2')
.get() as {
anilistId: number | null;
};
assert.equal(target.anilistId, null);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('dismissed weak collision stays dismissed when automatic resolution repeats', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
resolveAnimeAnilistConflict(db, 2, 163132);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('dismissed recommendation prevents a later exact automatic merge of the pair', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'weak' });
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('manual merge clears recommendations involving the absorbed entry', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
mergeAnimeRecords(db, 1, [2]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('resolveAnimeAnilistConflict keeps the target entry when the user drove the change', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
assert.equal(summary.survivingAnimeId, 2);
assert.deepEqual(animeIds(db), [2]);
assert.equal(videoAnimeId(db, 1), 2);
const row = db.prepare('SELECT anilist_id AS id FROM imm_anime WHERE anime_id = 2').get() as {
id: number | null;
};
assert.equal(row.id, 163132);
});
});
test('resolveAnimeAnilistConflict falls back to season redistribution for multi-season rows', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
// The mixed row is split by season instead of being poured onto one card.
const titles = (
db.prepare('SELECT canonical_title AS title FROM imm_anime ORDER BY title').all() as Array<{
title: string;
}>
).map((row) => row.title);
assert.deepEqual(titles, ['Show Season 1', 'Show Season 2']);
assert.equal(videoAnimeId(db, 1), 2);
assert.equal(videoAnimeId(db, 3), 2);
assert.notEqual(videoAnimeId(db, 2), 2);
});
});
test('season redistribution leaves manually assigned episodes in place', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
moveVideoToAnime(db, 1, 1);
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(assignmentLocked(db, 1), 1);
assert.notEqual(videoAnimeId(db, 2), 1);
assert.equal(summary.movedVideos, 1);
});
});
test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assignments unchanged', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
assert.equal(summary.repaired, 0);
assert.equal(summary.movedVideos, 0);
assert.equal(summary.deletedAnimeRows, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(videoAnimeId(db, 2), 2);
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('manual AniList resolution reassigns across explicit seasons without merging them', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
assert.equal(summary.anilistAssignmentBlocked, false);
assert.deepEqual(animeIds(db), [1, 2]);
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: null },
{ animeId: 2, anilistId: 163132 },
]);
});
});
test('automatic AniList update does not transfer an assignment across explicit seasons', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: true,
});
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(videoAnimeId(db, 2), 2);
});
});
test('automatic AniList update with unknown match confidence validates stored titles', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Actual Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
});
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('stored AniList titles ignore season suffixes when validating an automatic merge', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'legacy show',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show Season 1',
});
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.deletedAnimeRows, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere alone', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(videoAnimeId(db, 2), 2);
assert.ok(animeIds(db).includes(2));
assert.equal(
(
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
anilistId: number;
}
).anilistId,
999,
);
assert.equal(summary.repaired, 0);
assert.equal(summary.movedVideos, 0);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('automatic AniList update onto an entry that already links elsewhere does not throw', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
// Entry 2 explicitly links to 999; a later video re-resolving to entry 1's
// id must be refused, not written over the UNIQUE anilist_id column.
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: true,
});
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(
(
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
anilistId: number;
}
).anilistId,
999,
);
});
});
@@ -50,6 +50,7 @@ import {
updateAnimeAnilistInfo,
upsertCoverArt,
} from '../query-maintenance.js';
import { deleteMaintenanceBatch } from '../query-delete-maintenance.js';
import { getLocalEpochDay } from '../query-shared.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);
}
});
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);
}
});
@@ -0,0 +1,169 @@
import type { DatabaseSync } from './sqlite';
import { animeSeasonsAreMergeCompatible, getParsedSeasonsForAnime } from './anime-merge';
import { toDbTimestamp } from './query-shared';
import { normalizeAnimeIdentityKey } from './storage';
import { nowMs } from './time';
export interface AnimeMergeRecommendation {
recommendationId: number;
animeIds: [number, number];
}
export interface AnimeConflictRecommendationOptions {
survivor?: 'target' | 'existing';
/** Automatic matches must be exact; manual assignment is authoritative. */
matchConfidence?: 'exact' | 'weak' | 'manual';
}
interface AnimeTitleRow {
canonical_title: string;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
}
function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null {
return db
.prepare(
`SELECT canonical_title, title_romaji, title_english, title_native
FROM imm_anime
WHERE anime_id = ?`,
)
.get(animeId) as AnimeTitleRow | null;
}
function getParsedTitles(db: DatabaseSync, animeId: number): Array<string | null> {
return (
db.prepare('SELECT parsed_title FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{
parsed_title: string | null;
}>
).map((row) => row.parsed_title);
}
function stripSeasonIdentitySuffix(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, ' ');
}
export function hasExactStoredTitleMatch(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
): boolean {
const target = getAnimeTitles(db, targetAnimeId);
const conflict = getAnimeTitles(db, conflictAnimeId);
if (!target || !conflict) return false;
const targetKeys = [target.canonical_title, ...getParsedTitles(db, targetAnimeId)]
.filter((title): title is string => Boolean(title?.trim()))
.map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
.filter(Boolean);
const anilistTitleKeys = [
conflict.title_romaji,
conflict.title_english,
conflict.title_native,
conflict.canonical_title,
]
.filter((title): title is string => Boolean(title?.trim()))
.map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
.filter(Boolean);
return targetKeys.some((key) => anilistTitleKeys.includes(key));
}
export function shouldRecommendAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
options: AnimeConflictRecommendationOptions,
): boolean {
if (options.survivor === 'target' || options.matchConfidence === 'manual') return false;
if (
!animeSeasonsAreMergeCompatible(
getParsedSeasonsForAnime(db, targetAnimeId),
getParsedSeasonsForAnime(db, conflictAnimeId),
)
) {
return false;
}
return (
options.matchConfidence === 'weak' ||
(options.matchConfidence === undefined &&
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
);
}
export function recordAnimeMergeRecommendation(
db: DatabaseSync,
firstCandidateAnimeId: number,
secondCandidateAnimeId: number,
anilistId: number,
): void {
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
const timestamp = toDbTimestamp(nowMs());
db.prepare(
`INSERT INTO imm_anime_merge_recommendations(
first_anime_id, second_anime_id, anilist_id, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, 'pending', ?, ?)
ON CONFLICT(first_anime_id, second_anime_id, anilist_id) DO UPDATE SET
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
).run(firstAnimeId, secondAnimeId, anilistId, timestamp, timestamp);
}
export function hasDismissedAnimeMergeRecommendation(
db: DatabaseSync,
firstCandidateAnimeId: number,
secondCandidateAnimeId: number,
): boolean {
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
return Boolean(
db
.prepare(
`SELECT 1
FROM imm_anime_merge_recommendations
WHERE first_anime_id = ?
AND second_anime_id = ?
AND status = 'dismissed'
LIMIT 1`,
)
.get(firstAnimeId, secondAnimeId),
);
}
export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecommendation[] {
return (
db
.prepare(
`SELECT recommendation_id AS recommendationId,
first_anime_id AS firstAnimeId,
second_anime_id AS secondAnimeId
FROM imm_anime_merge_recommendations
WHERE status = 'pending'
ORDER BY recommendation_id ASC`,
)
.all() as Array<{
recommendationId: number;
firstAnimeId: number;
secondAnimeId: number;
}>
).map((row) => ({
recommendationId: row.recommendationId,
animeIds: [row.firstAnimeId, row.secondAnimeId],
}));
}
export function dismissAnimeMergeRecommendation(
db: DatabaseSync,
recommendationId: number,
): boolean {
const result = db
.prepare(
`UPDATE imm_anime_merge_recommendations
SET status = 'dismissed', LAST_UPDATE_DATE = ?
WHERE recommendation_id = ? AND status = 'pending'`,
)
.run(toDbTimestamp(nowMs()), recommendationId) as { changes: number };
return result.changes > 0;
}
@@ -0,0 +1,296 @@
import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
/** Thrown when a move names an episode or destination entry that is not there. */
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
survivingAnimeId: number;
/** Entries that were folded into the survivor and deleted. */
mergedAnimeIds: number[];
movedVideos: number;
}
export interface VideoMoveSummary {
targetAnimeId: number;
/** Previous owner, or null when the episode had no library entry yet. */
previousAnimeId: number | null;
/** True when the previous owner was left empty and pruned. */
removedPreviousAnime: boolean;
}
interface AnimeMetadataRow {
normalized_title_key: string;
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
episodes_total: number | null;
description: string | null;
}
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
return { survivingAnimeId, mergedAnimeIds: [], movedVideos: 0 };
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN IMMEDIATE');
try {
const result = work();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow | null {
return (db
.prepare(
`
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
FROM imm_anime
WHERE anime_id = ?
`,
)
.get(animeId) ?? null) as AnimeMetadataRow | null;
}
function animeExists(db: DatabaseSync, animeId: number): boolean {
return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
const row = db
.prepare(
`
SELECT 1 AS found
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
`,
)
.get(animeId, animeId) as { found: number } | null;
return Boolean(row);
}
/**
* Distinct explicit seasons behind a library entry. Videos with no parsed
* season are ignored, so an entry built from `Show - 03.mkv` style filenames
* reports an empty set rather than a bogus season.
*/
export function getParsedSeasonsForAnime(db: DatabaseSync, animeId: number): Set<number> {
const rows = db
.prepare(
`
SELECT DISTINCT parsed_season AS season
FROM imm_videos
WHERE anime_id = ?
AND parsed_season IS NOT NULL
AND parsed_season > 0
`,
)
.all(animeId) as Array<{ season: number }>;
return new Set(rows.map((row) => row.season));
}
/**
* Two entries are safe to fold together when neither spans more than one
* explicit season and they do not disagree about which season that is. A
* seasonless entry is compatible with anything single-season: those are the
* `Show - 03.mkv` vs `Show.S01E03.mkv` splits that produce duplicate cards.
*/
export function animeSeasonsAreMergeCompatible(a: Set<number>, b: Set<number>): boolean {
if (a.size > 1 || b.size > 1) return false;
if (a.size === 0 || b.size === 0) return true;
return [...a][0] === [...b][0];
}
/**
* Fill in whatever the target is missing from a source row that is on its way
* out. Must run after the source row is deleted: imm_anime.anilist_id is
* UNIQUE, so the two rows cannot hold the same id at once.
*/
function absorbAnimeMetadata(
db: DatabaseSync,
targetAnimeId: number,
source: AnimeMetadataRow | null,
updatedAt: string,
): void {
if (!source) return;
db.prepare(
`
UPDATE imm_anime
SET
anilist_id = COALESCE(anilist_id, ?),
title_romaji = COALESCE(title_romaji, ?),
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?),
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
).run(
source.anilist_id,
source.title_romaji,
source.title_english,
source.title_native,
source.episodes_total,
source.description,
updatedAt,
targetAnimeId,
);
}
/**
* Fold `sourceAnimeIds` into `targetAnimeId`: every episode and subtitle line
* is repointed, metadata the target is missing is inherited from the sources,
* and the emptied source rows are deleted.
*
* Assumes the caller already holds a write transaction and refreshes the
* per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords}
* otherwise.
*/
export function mergeAnimeRecordsInTransaction(
db: DatabaseSync,
targetAnimeId: number,
sourceAnimeIds: number[],
): AnimeMergeSummary {
const summary = emptyMergeSummary(targetAnimeId);
if (!animeExists(db, targetAnimeId)) {
return summary;
}
const updatedAt = toDbTimestamp(nowMs());
const sourceVideosStmt = db.prepare(
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
);
const moveVideosStmt = db.prepare(
'UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
);
// Repointed per video rather than by anime_id: lines recorded before the
// async title parse assigns the link are stored with a NULL anime_id, and
// matching on the source id would strand them unattributed.
const moveLinesStmt = db.prepare(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
);
const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
const sourceAliasesStmt = db.prepare(
'SELECT normalized_title_key AS normalizedTitleKey FROM imm_anime_title_aliases WHERE anime_id = ?',
);
const upsertAliasStmt = db.prepare(
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(normalized_title_key) DO UPDATE SET
anime_id = excluded.anime_id,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
);
const dropSourceAliasesStmt = db.prepare(
'DELETE FROM imm_anime_title_aliases WHERE anime_id = ?',
);
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
continue;
}
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
const sourceAliases = sourceAliasesStmt.all(sourceAnimeId) as Array<{
normalizedTitleKey: string;
}>;
const sourceVideoIds = (sourceVideosStmt.all(sourceAnimeId) as Array<{ videoId: number }>).map(
(row) => row.videoId,
);
const moved = moveVideosStmt.run(targetAnimeId, updatedAt, sourceAnimeId) as {
changes: number;
};
for (const videoId of sourceVideoIds) {
moveLinesStmt.run(targetAnimeId, updatedAt, videoId);
}
dropSourceAliasesStmt.run(sourceAnimeId);
for (const alias of [
...(sourceMetadata ? [sourceMetadata.normalized_title_key] : []),
...sourceAliases.map((row) => row.normalizedTitleKey),
]) {
upsertAliasStmt.run(alias, targetAnimeId, updatedAt, updatedAt);
}
dropLifetimeStmt.run(sourceAnimeId);
dropAnimeStmt.run(sourceAnimeId);
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
summary.mergedAnimeIds.push(sourceAnimeId);
summary.movedVideos += moved.changes;
}
return summary;
}
export function mergeAnimeRecords(
db: DatabaseSync,
targetAnimeId: number,
sourceAnimeIds: number[],
): AnimeMergeSummary {
return runInTransaction(db, () => {
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
if (summary.mergedAnimeIds.length > 0) {
recomputeLifetimeAnimeAggregatesInTransaction(db);
}
return summary;
});
}
/**
* Move a single episode to another library entry, pruning the previous owner
* when it is left with nothing.
*/
export function moveVideoToAnime(
db: DatabaseSync,
videoId: number,
targetAnimeId: number,
): VideoMoveSummary {
return runInTransaction(db, () => {
const videoRow = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
if (!videoRow || !animeExists(db, targetAnimeId)) {
throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE);
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId === targetAnimeId) {
db.prepare(
'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?',
).run(toDbTimestamp(nowMs()), videoId);
return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
}
const updatedAt = toDbTimestamp(nowMs());
db.prepare(
`UPDATE imm_videos
SET anime_id = ?, anime_assignment_locked = 1, LAST_UPDATE_DATE = ?
WHERE video_id = ?`,
).run(targetAnimeId, updatedAt, videoId);
db.prepare(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
).run(targetAnimeId, updatedAt, videoId);
let removedPreviousAnime = false;
if (previousAnimeId !== null && !hasAnimeReferences(db, previousAnimeId)) {
// The emptied entry's metadata is deliberately dropped rather than
// absorbed. A move says "this episode belongs elsewhere", not "these are
// the same show", and the entry being emptied is usually a mis-parse
// whose AniList link would be wrong for the target.
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(previousAnimeId);
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(previousAnimeId);
removedPreviousAnime = true;
}
recomputeLifetimeAnimeAggregatesInTransaction(db);
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
});
}
@@ -1,4 +1,16 @@
import type { DatabaseSync } from './sqlite';
import {
animeSeasonsAreMergeCompatible,
getParsedSeasonsForAnime,
mergeAnimeRecordsInTransaction,
} from './anime-merge';
import {
hasExactStoredTitleMatch,
hasDismissedAnimeMergeRecommendation,
recordAnimeMergeRecommendation,
shouldRecommendAnilistConflict,
type AnimeConflictRecommendationOptions,
} from './anime-merge-recommendations';
import { getOrCreateAnimeRecord } from './storage';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
@@ -8,8 +20,33 @@ export interface AnimeSeasonRepairSummary {
repaired: number;
movedVideos: number;
deletedAnimeRows: number;
/**
* Entry that owns the videos afterwards when two rows were folded together,
* so callers can keep pointing at a row that still exists.
*/
survivingAnimeId: number | null;
/** True when an ambiguous AniList collision was saved for user review. */
mergeRecommended: boolean;
/** True when automatic metadata must not assign the colliding AniList id. */
anilistAssignmentBlocked: boolean;
}
export interface AnimeAnilistConflictOptions extends AnimeConflictRecommendationOptions {
/**
* Which row keeps its identity when two entries claim the same AniList id.
* `existing` (the default) keeps the row that already held the id, so
* automatic cover-art resolution does not rename a card under the user;
* `target` keeps the row the user is acting on.
*/
survivor?: 'target' | 'existing';
}
export {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
type AnimeMergeRecommendation,
} from './anime-merge-recommendations';
interface AnimeRow {
anime_id: number;
anilist_id: number | null;
@@ -24,6 +61,7 @@ interface ParsedVideoRow {
video_id: number;
parsed_title: string | null;
parsed_season: number | null;
anime_assignment_locked: number;
}
interface RedistributeOptions {
@@ -38,6 +76,9 @@ function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
repaired: 0,
movedVideos: 0,
deletedAnimeRows: 0,
survivingAnimeId: null,
mergeRecommended: false,
anilistAssignmentBlocked: false,
};
}
@@ -49,6 +90,9 @@ function mergeSummary(
target.repaired += source.repaired;
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
target.mergeRecommended ||= source.mergeRecommended;
target.anilistAssignmentBlocked ||= source.anilistAssignmentBlocked;
return target;
}
@@ -94,7 +138,7 @@ function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
return db
.prepare(
`
SELECT video_id, parsed_title, parsed_season
SELECT video_id, parsed_title, parsed_season, anime_assignment_locked
FROM imm_videos
WHERE anime_id = ?
ORDER BY video_id ASC
@@ -188,6 +232,9 @@ function redistributeAnimeRowByParsedSeasonsInTransaction(
const targetBySeason = new Map<number, number>();
for (const video of videos) {
if (video.anime_assignment_locked === 1) {
continue;
}
const parsedTitle = video.parsed_title?.trim();
const season = normalizeSeason(video.parsed_season);
if (!parsedTitle || season === null) {
@@ -301,10 +348,18 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
});
}
/**
* Two library entries cannot both hold the same AniList id
* (`imm_anime.anilist_id` is UNIQUE). Fold an automatic collision only when
* exact title evidence and compatible parsed seasons make it safe. Persist a
* review recommendation for compatible weak matches. Fall back to legacy
* season redistribution when the conflicting row spans several seasons.
*/
export function resolveAnimeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
const conflict = db
.prepare(
@@ -321,10 +376,100 @@ export function resolveAnimeAnilistConflict(
return emptySummary();
}
return runInTransaction(db, () =>
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
return runInTransaction(db, () => {
const targetRow = getAnimeRow(db, targetAnimeId);
if (
options.survivor !== 'target' &&
targetRow?.anilist_id != null &&
targetRow.anilist_id !== anilistId
) {
// An automatic lookup disagreeing with an existing explicit link is a
// mis-resolution, not evidence that either row should move or merge. The
// colliding id must not be assigned either: another row owns it and
// imm_anime.anilist_id is UNIQUE.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
if (
!isManual &&
targetSeasons.size === 1 &&
conflictSeasons.size === 1 &&
[...targetSeasons][0] !== [...conflictSeasons][0]
) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
const summary = emptySummary(1);
summary.movedVideos = merge.movedVideos;
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
if (merge.mergedAnimeIds.length > 0) {
summary.repaired = 1;
// Only reported once a row really absorbed the other, so callers never
// follow this to an anime id that was never written.
summary.survivingAnimeId = survivingAnimeId;
}
// Lifetime summaries are rebuilt by the caller off this summary, the same
// as the redistribution path below.
return summary;
}
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
const summary = emptySummary(1);
summary.mergeRecommended = true;
return summary;
}
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
}),
});
});
}
function canMergeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions,
): boolean {
const targetRow = getAnimeRow(db, targetAnimeId);
if (!targetRow) {
// Nothing to merge with a row that no longer exists (a stale id from the
// caller); fall through to the redistribution path.
return false;
}
if (options.survivor !== 'target') {
// The target is the row about to disappear here, so an existing link of its
// own means this is a mis-resolution rather than a duplicate: leave it be.
if (targetRow.anilist_id != null && targetRow.anilist_id !== anilistId) {
return false;
}
}
if (
options.matchConfidence === 'weak' ||
(options.matchConfidence === undefined &&
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
) {
return false;
}
return animeSeasonsAreMergeCompatible(
getParsedSeasonsForAnime(db, targetAnimeId),
getParsedSeasonsForAnime(db, conflictAnimeId),
);
}
@@ -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();
}
}
@@ -8,6 +8,8 @@ import type { JellyfinLinkRepairSummary } from './types';
type LegacyJellyfinVideoRow = {
video_id: number;
video_key: string;
anime_id: number | null;
anime_assignment_locked: number;
source_url: string | null;
canonical_title: string;
};
@@ -15,6 +17,7 @@ type LegacyJellyfinVideoRow = {
type JellyfinTargetVideoRow = {
video_id: number;
anime_id: number | null;
anime_assignment_locked: number;
canonical_title: string;
parsed_basename: string | null;
parsed_title: string | null;
@@ -258,7 +261,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
const candidates = db
.prepare(
`
SELECT video_id, video_key, source_url, canonical_title
SELECT
video_id,
video_key,
anime_id,
anime_assignment_locked,
source_url,
canonical_title
FROM imm_videos
WHERE source_type = 2
AND (
@@ -310,6 +319,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
SELECT
video_id,
anime_id,
anime_assignment_locked,
canonical_title,
parsed_basename,
parsed_title,
@@ -357,12 +367,17 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
continue;
}
const assignmentAnimeId =
candidate.anime_assignment_locked === 1 ? candidate.anime_id : target.anime_id;
const assignmentLocked =
candidate.anime_assignment_locked === 1 || target.anime_assignment_locked === 1 ? 1 : 0;
db.prepare(
`
UPDATE imm_videos
SET
video_key = ?,
anime_id = ?,
anime_assignment_locked = ?,
canonical_title = ?,
source_url = ?,
parsed_basename = ?,
@@ -377,7 +392,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
`,
).run(
sanitizedVideoKey,
target.anime_id,
assignmentAnimeId,
assignmentLocked,
target.canonical_title,
statsUrl,
target.parsed_basename,
@@ -390,14 +406,14 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
currentTimestamp,
candidate.video_id,
);
if (target.anime_id !== null) {
if (assignmentAnimeId !== null) {
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(target.anime_id, currentTimestamp, candidate.video_id);
).run(assignmentAnimeId, currentTimestamp, candidate.video_id);
}
summary.repaired += 1;
}
@@ -708,6 +708,87 @@ export function rebuildLifetimeSummariesInTransaction(
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
}
/**
* Re-derive every per-anime lifetime row from the per-video summaries after
* episodes changed owners (merge, move, season repair).
*
* Deliberately NOT a full rebuild: {@link rebuildLifetimeSummariesInTransaction}
* recomputes from raw sessions, which are pruned after the retention window, so
* it silently truncates lifetime history. `imm_lifetime_media` is keyed by
* video and survives repointing, so aggregating it preserves all-time totals;
* `imm_lifetime_global` only needs `anime_completed` refreshed because moving
* attribution between entries cannot change the global counters.
*
* Assumes the caller holds a write transaction; use
* {@link recomputeLifetimeAnimeAggregates} otherwise.
*/
export function recomputeLifetimeAnimeAggregatesInTransaction(db: DatabaseSync): void {
const updatedAt = toDbTimestamp(nowMs());
db.exec('DELETE FROM imm_lifetime_anime');
db.prepare(
`
INSERT INTO imm_lifetime_anime (
anime_id,
total_sessions,
total_active_ms,
total_cards,
total_lines_seen,
total_tokens_seen,
episodes_started,
episodes_completed,
first_watched_ms,
last_watched_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
v.anime_id,
COALESCE(SUM(m.total_sessions), 0),
COALESCE(SUM(m.total_active_ms), 0),
COALESCE(SUM(m.total_cards), 0),
COALESCE(SUM(m.total_lines_seen), 0),
COALESCE(SUM(m.total_tokens_seen), 0),
COUNT(*),
COUNT(CASE WHEN m.completed > 0 THEN 1 END),
MIN(m.first_watched_ms),
MAX(m.last_watched_ms),
?,
?
FROM imm_lifetime_media m
JOIN imm_videos v ON v.video_id = m.video_id
WHERE v.anime_id IS NOT NULL
GROUP BY v.anime_id
`,
).run(updatedAt, updatedAt);
db.prepare(
`
UPDATE imm_lifetime_global
SET
anime_completed = (
SELECT COUNT(*)
FROM imm_lifetime_anime la
JOIN imm_anime a ON a.anime_id = la.anime_id
WHERE a.episodes_total IS NOT NULL
AND a.episodes_total > 0
AND la.episodes_completed >= a.episodes_total
),
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(updatedAt);
}
export function recomputeLifetimeAnimeAggregates(db: DatabaseSync): void {
db.exec('BEGIN IMMEDIATE');
try {
recomputeLifetimeAnimeAggregatesInTransaction(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
const sessions = getRetainedStaleActiveSessions(db);
if (sessions.length === 0) {
@@ -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;
}
}
@@ -1,7 +1,10 @@
import { createHash } from 'node:crypto';
import type { DatabaseSync } from './sqlite';
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
import {
recomputeLifetimeAnimeAggregates,
rebuildLifetimeSummariesInTransaction,
} from './lifetime';
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
import { nowMs } from './time';
import { resolveAnimeAnilistConflict } from './anime-season-repair';
@@ -418,6 +421,7 @@ export function updateAnimeAnilistInfo(
titleEnglish: string | null;
titleNative: string | null;
episodesTotal: number | null;
exactTitleMatch?: boolean;
},
): void {
const row = db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
@@ -425,7 +429,11 @@ export function updateAnimeAnilistInfo(
} | null;
if (!row?.anime_id) return;
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId, {
matchConfidence:
info.exactTitleMatch === true ? 'exact' : info.exactTitleMatch === false ? 'weak' : undefined,
});
if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return;
const targetRow = db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(videoId) as {
@@ -455,7 +463,7 @@ export function updateAnimeAnilistInfo(
targetRow.anime_id,
);
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaries(db);
recomputeLifetimeAnimeAggregates(db);
}
}
@@ -80,6 +80,14 @@ export function makePlaceholders(values: number[]): string {
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 {
return `COALESCE(${blobStoreAlias}.cover_blob, CASE WHEN ${mediaAlias}.cover_blob_hash IS NULL THEN ${mediaAlias}.cover_blob ELSE NULL END)`;
}
@@ -490,17 +498,19 @@ export function deleteSessionsByIds(db: DatabaseSync, sessionIds: number[]): voi
return;
}
const placeholders = makePlaceholders(sessionIds);
forEachIdChunk(sessionIds, (chunk) => {
const placeholders = makePlaceholders(chunk);
db.prepare(`DELETE FROM imm_subtitle_lines WHERE session_id IN (${placeholders})`).run(
...sessionIds,
...chunk,
);
db.prepare(`DELETE FROM imm_session_telemetry WHERE session_id IN (${placeholders})`).run(
...sessionIds,
...chunk,
);
db.prepare(`DELETE FROM imm_session_events WHERE session_id IN (${placeholders})`).run(
...sessionIds,
...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 {
@@ -20,6 +20,7 @@ import {
} from './storage';
import {
EVENT_SUBTITLE_LINE,
SCHEMA_VERSION,
SESSION_STATUS_ENDED,
SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE,
@@ -132,6 +133,7 @@ test('ensureSchema creates immersion core tables', () => {
assert.ok(videoColumns.has('parser_source'));
assert.ok(videoColumns.has('parser_confidence'));
assert.ok(videoColumns.has('parse_metadata_json'));
assert.ok(videoColumns.has('anime_assignment_locked'));
const mediaArtColumns = new Set(
(
@@ -155,6 +157,33 @@ test('ensureSchema creates immersion core tables', () => {
}
});
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.exec('ALTER TABLE imm_videos DROP COLUMN anime_assignment_locked');
db.prepare('UPDATE imm_schema_version SET schema_version = ?').run(SCHEMA_VERSION - 1);
ensureSchema(db);
const columns = new Set(
(db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{ name: string }>).map(
(row) => row.name,
),
);
assert.ok(columns.has('anime_assignment_locked'));
const version = db
.prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version')
.get() as { version: number };
assert.equal(version.version, SCHEMA_VERSION);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('stats excluded words are replaced and read from sqlite storage', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -807,6 +836,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
assert.ok(videoColumns.has('parser_source'));
assert.ok(videoColumns.has('parser_confidence'));
assert.ok(videoColumns.has('parse_metadata_json'));
assert.ok(videoColumns.has('anime_assignment_locked'));
const animeRows = db
.prepare('SELECT canonical_title FROM imm_anime ORDER BY canonical_title')
+115 -11
View File
@@ -1,5 +1,7 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { SCHEMA_VERSION } from './types';
@@ -319,14 +321,7 @@ export function applyPragmas(db: DatabaseSync): void {
db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`);
}
export function normalizeAnimeIdentityKey(title: string): string {
return title
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
}
export const normalizeAnimeIdentityKey = normalizeTitleIdentity;
function normalizeSeasonScope(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
@@ -530,6 +525,36 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
`);
}
function ensureAnimeMergeTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_anime_title_aliases(
normalized_title_key TEXT PRIMARY KEY,
anime_id INTEGER NOT NULL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_anime_title_aliases_anime_id
ON imm_anime_title_aliases(anime_id);
CREATE TABLE IF NOT EXISTS imm_anime_merge_recommendations(
recommendation_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_anime_id INTEGER NOT NULL,
second_anime_id INTEGER NOT NULL,
anilist_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dismissed')),
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
CHECK(first_anime_id < second_anime_id),
UNIQUE(first_anime_id, second_anime_id, anilist_id),
FOREIGN KEY(first_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE,
FOREIGN KEY(second_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_anime_merge_recommendations_status
ON imm_anime_merge_recommendations(status, recommendation_id);
`);
}
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
@@ -550,8 +575,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle;
const byTitleAlias = db
.prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
if (existing?.anime_id) {
// An alias remembers an intentionally merged-away spelling. Reusing it
// must not rename the survivor back to that discarded display title.
const canonicalTitleUpdate = byAnilistId || byNormalizedTitle ? canonicalTitle : null;
db.prepare(
`
UPDATE imm_anime
@@ -566,7 +597,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
WHERE anime_id = ?
`,
).run(
canonicalTitle,
canonicalTitleUpdate,
input.anilistId,
input.titleRomaji,
input.titleEnglish,
@@ -618,7 +649,10 @@ export function linkVideoToAnimeRecord(
`
UPDATE imm_videos
SET
anime_id = ?,
anime_id = CASE
WHEN anime_assignment_locked = 1 THEN anime_id
ELSE ?
END,
parsed_basename = ?,
parsed_title = ?,
parsed_season = ?,
@@ -643,6 +677,67 @@ export function linkVideoToAnimeRecord(
);
}
export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null {
const row = db
.prepare(
`
SELECT anime_id AS animeId
FROM imm_videos
WHERE video_id = ?
AND anime_assignment_locked = 1
`,
)
.get(videoId) as { animeId: number | null } | null;
return row?.animeId ?? null;
}
/**
* A manual correction in the same folder is a useful grouping hint, but only
* when every season-compatible correction agrees on the destination.
*/
export function findManualDirectoryAnimeAssignment(
db: DatabaseSync,
videoId: number,
mediaPath: string,
parsedSeason: number | null,
): number | null {
const directory = path.dirname(path.resolve(mediaPath));
const rows = db
.prepare(
`
SELECT
anime_id AS animeId,
source_path AS sourcePath,
parsed_season AS parsedSeason
FROM imm_videos
WHERE video_id != ?
AND anime_assignment_locked = 1
AND anime_id IS NOT NULL
AND source_path IS NOT NULL
`,
)
.all(videoId) as Array<{
animeId: number;
sourcePath: string;
parsedSeason: number | null;
}>;
const candidates = new Set<number>();
for (const row of rows) {
if (path.dirname(path.resolve(row.sourcePath)) !== directory) {
continue;
}
if (parsedSeason !== null && row.parsedSeason !== null && parsedSeason !== row.parsedSeason) {
continue;
}
candidates.add(row.animeId);
if (candidates.size > 1) {
return null;
}
}
return candidates.values().next().value ?? null;
}
export function linkYoutubeVideoToAnimeRecord(
db: DatabaseSync,
videoId: number,
@@ -751,6 +846,7 @@ export function ensureSchema(db: DatabaseSync): void {
if (currentVersion?.schema_version === SCHEMA_VERSION) {
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
ensureAnimeMergeTables(db);
return;
}
@@ -786,6 +882,7 @@ export function ensureSchema(db: DatabaseSync): void {
parser_source TEXT,
parser_confidence REAL,
parse_metadata_json TEXT,
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
watched INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
@@ -799,6 +896,13 @@ export function ensureSchema(db: DatabaseSync): void {
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
`);
addColumnIfMissing(
db,
'imm_videos',
'anime_assignment_locked',
'INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1))',
);
ensureAnimeMergeTables(db);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_sessions(
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
+1 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 19;
export const SCHEMA_VERSION = 21;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
+5 -1
View File
@@ -1,5 +1,9 @@
export { Texthooker } from './texthooker';
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
export {
hasMpvWebsocketPlugin,
isSubtitleAnnotationUpgrade,
SubtitleWebSocket,
} from './subtitle-ws';
export { registerGlobalShortcuts } from './shortcut';
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
+15
View File
@@ -1,6 +1,7 @@
import electron from 'electron';
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type {
ChangelogSnapshot,
CompiledSessionBinding,
ControllerConfigUpdate,
PlaylistBrowserMutationResult,
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
entries: [],
})),
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getChangelogSnapshot: options.getChangelogSnapshot,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
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 () => {
return await deps.getPlaylistBrowserSnapshot();
});
+2 -2
View File
@@ -205,7 +205,7 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
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 args = makeArgs({ background: true });
@@ -222,7 +222,7 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
});
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', () => {
+1 -1
View File
@@ -45,7 +45,7 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background || initialArgs.update) {
} else if (initialArgs.update) {
deps.setLogLevel('warn', 'cli');
}
@@ -1,5 +1,6 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import { UNKNOWN_MOVE_TARGET_MESSAGE } from '../immersion-tracker/anime-merge.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildSentenceSearchOptions,
@@ -7,6 +8,7 @@ import {
parseBooleanQuery,
parseExcludedWordsBody,
parseIntQuery,
parsePositiveIdList,
} from './route-support.js';
export function registerStatsLibraryRoutes(
@@ -130,6 +132,19 @@ export function registerStatsLibraryRoutes(
return c.json(statsJson('animeLibrary', rows));
});
app.get('/api/stats/anime/merge-recommendations', async (c) => {
const recommendations = await tracker.getAnimeMergeRecommendations();
return c.json(statsJson('animeMergeRecommendations', { recommendations }));
});
app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => {
const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0);
if (recommendationId <= 0) return c.body(null, 400);
const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId);
if (!dismissed) return c.body(null, 404);
return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true }));
});
app.get('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
@@ -197,4 +212,50 @@ export function registerStatsLibraryRoutes(
await tracker.deleteAnime(animeId);
return c.json(statsJson('deleteAnime', { ok: true }));
});
app.post('/api/stats/anime/:animeId/merge', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId);
if (sourceAnimeIds.length === 0) return c.body(null, 400);
const summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
// Nothing folded means the target or every source was already gone, so the
// caller should not be told the merge succeeded.
if (summary.mergedAnimeIds.length === 0) return c.body(null, 404);
return c.json(
statsJson('mergeAnime', {
ok: true,
animeId: summary.survivingAnimeId,
mergedAnimeIds: summary.mergedAnimeIds,
movedVideos: summary.movedVideos,
}),
);
});
app.patch('/api/stats/media/:videoId/anime', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0;
if (animeId <= 0) return c.body(null, 400);
try {
const summary = await tracker.moveVideoToAnime(videoId, animeId);
return c.json(
statsJson('moveVideoToAnime', {
ok: true,
animeId: summary.targetAnimeId,
previousAnimeId: summary.previousAnimeId,
removedPreviousAnime: summary.removedPreviousAnime,
}),
);
} catch (error) {
// Only a missing episode or entry is a 404; storage failures must not be
// reported to the caller as "not found".
if (error instanceof Error && error.message === UNKNOWN_MOVE_TARGET_MESSAGE) {
return c.body(null, 404);
}
throw error;
}
});
}
@@ -11,7 +11,7 @@ import {
resolveSecondarySubtitleTextFromSidecar,
} from '../secondary-subtitle-sidecar.js';
import {
applyStatsWordAndSentenceCardFields,
applyStatsWordCardFields,
createStatsMiningContext,
getStatsDirectMiningAudioFieldNames,
getStatsWordMiningAudioFieldName,
@@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence;
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
@@ -1,5 +1,7 @@
import type { MediaGenerator } from '../../../media-generator.js';
import type { AnkiConnectConfig } from '../../../types.js';
import { applyCardKindFlagFields } from '../../../anki-integration/card-kinds.js';
import { resolveWordCardKind } from '../../../anki-integration/note-field-utils.js';
import { createLogger } from '../../../logger.js';
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
@@ -94,20 +96,22 @@ export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig)
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
}
export function applyStatsWordAndSentenceCardFields(
export function applyStatsWordCardFields(
fields: Record<string, string>,
noteInfo: StatsServerNoteInfo | null,
ankiConfig: AnkiConnectConfig,
): void {
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
if (!noteInfo) return;
const cardKind = resolveWordCardKind(noteInfo, {
lapisEnabled: ankiConfig.isLapis?.enabled === true,
kikuEnabled: ankiConfig.isKiku?.enabled === true,
wordCardKind: ankiConfig.lapisKiku?.wordCardKind,
});
if (!cardKind) return;
fields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
}
applyCardKindFlagFields(fields, cardKind, (preferredName) =>
resolveStatsNoteFieldName(noteInfo, preferredName),
);
}
export function getStatsDirectMiningAudioFieldNames(

Some files were not shown because too many files have changed in this diff Show More